Skip to content

    Forward Deployed Engineer

    FastAPI Service for AI Apps, Compared with Spring Boot

    Build a FastAPI service: typed request and response models, automatic docs, Depends compared with Spring beans, and the capstone's create_app and routes.

    By Published 10 min read

    Share

    This lesson is part of Forward Deployed Engineer: Python and FastAPI Foundations. Enroll for free, then sign in, to read it in full.

    What you'll learn: how a FastAPI service is put together, using the capstone's create_app as the example: routes declared with typed request and response models, the documentation that comes for free, what happens to a request between the network and your function, and how Depends compares with Spring beans and constructor injection.

    At a glance

    Figure 1. How create_app assembles the service

    flowchart LR accTitle: How create_app assembles the service accDescr: Settings and services go into create_app. It builds one FastAPI application with a trace id middleware, error handlers, shared state, and a router under the api v1 prefix with the health, discovery, product search and dev token routes. The application publishes an OpenAPI description that drives the interactive docs. S["Settings and Services"] --> C["create_app"] C --> A["FastAPI app"] A --> M["Middleware: trace id"] A --> E["Error handlers: one error shape"] A --> T["app.state: services, idempotency store"] A --> R["Router /api/v1: health, discovery, products/search, dev/token"] A --> D["OpenAPI description: /docs, /redoc, /openapi.json"]

    How to read this: everything the service needs is wired inside one function that takes its settings and services as arguments, so a test can build its own copy.

    • A route is a plain function whose parameter types describe the request, and whose return type or response_model describes the reply.
    • FastAPI validates the request before your function runs and generates the API documentation from the same declarations.
    • Depends is a lighter cousin of Spring injection: you name the function that provides a value, and it runs per request.
    • The capstone passes shared objects through create_app arguments and app.state instead of Depends, which is a valid choice too.
    • Tests swap a dependency with app.dependency_overrides, with no change to the route.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply, the fictional distributor, wants the pilot's discovery feature behind an HTTP API so its portal, written in React, can call it. The platform team is used to Spring Boot controllers and wants to know what the Python service looks like, how clients learn its contract, and how to test a route without a real identity provider. All names here are illustrative.

    What you will be able to do

    • Read a FastAPI route and say how each parameter is read from the request.
    • Explain, in a table, how Depends differs from Spring bean injection in lifetime and wiring.
    • Build a small service with a typed response, a dependency and a test that replaces the dependency.
    • Describe the order in which middleware, validation, dependencies and your function run.

    Prerequisites

    Python for Java and Node engineers and Dataclasses and Pydantic come first: FastAPI uses Pydantic models as its request and response contracts. The next lessons add async and retries and errors, configuration and logging.

    Core ideas

    A route is a decorated function

    Verified: capstone/fde-product-discovery-copilot/app/main.py

        @api.post("/discovery", response_model=DiscoveryResponse)
        def discovery(
            body: DiscoveryRequest,
            request: Request,
            response: Response,
            authorization: Annotated[str | None, Header()] = None,
            idempotency_key: Annotated[str | None, Header(max_length=100)] = None,
        ) -> dict[str, object]:
            identity = who(authorization)
            permissions.require(identity, "discovery")

    A decorator is a callable applied to the function beneath it, and what it returns takes the function's name. Here @api.post(...) registers the function as a route. The FastAPI body documentation lists what a typed parameter buys: it will "read the body of the request as JSON", "convert the corresponding types", "validate the data" and "generate JSON Schema definitions for your model".

    Parameter in the capstone Where FastAPI reads it from
    body: DiscoveryRequest The JSON body, validated by the Pydantic model
    request: Request, response: Response The raw request and response objects, for headers and state
    authorization: Annotated[str | None, Header()] The Authorization header, optional
    idempotency_key: Annotated[str | None, Header(max_length=100)] The Idempotency-Key header: FastAPI turns underscores into hyphens by default

    response_model=DiscoveryResponse tells FastAPI to validate and filter what the function returns. The docs say it will "convert and filter the output data" to that type, so a field that is not in the model never leaves the service.

    What happens to a request

    Predict first. A client sends POST /api/v1/discovery with a valid token and the JSON body {"request": "hi"}. The model says request needs at least 3 characters. What status code comes back, what is in the body, and does run_discovery run?

    Answer

    Status 422. The body is the capstone's single error shape, {"error": {"code": "validation_error", "message": "Invalid input: request", "trace_id": ...}}, and the trace id matches the X-Trace-Id response header. The route function never starts, so run_discovery is not called. The test below runs exactly this request.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L3/test_m1_l3_service.py

    def test_capstone_rejects_a_two_character_request_with_its_error_shape():
        app = create_app(Settings())
        r = TestClient(app).post("/api/v1/discovery", json={"request": "hi"}, headers=bearer_for_capstone())
        assert r.status_code == 422
        body = r.json()["error"]
        assert body["code"] == "validation_error" and body["message"] == "Invalid input: request"
        assert body["trace_id"] == r.headers["X-Trace-Id"]

    Figure 2. Request lifecycle of POST /api/v1/discovery

    sequenceDiagram accTitle: Request lifecycle of a discovery call accDescr: The client sends a request to the ASGI server, which passes it to the trace id middleware. The middleware sets a trace id and calls the application. FastAPI validates the body and headers and runs dependencies. If valid, the handler runs and the response model filters its result. If invalid, an error handler builds the 422 body. The middleware adds the trace id header on the way out. participant C as Client participant S as uvicorn participant M as Middleware participant F as FastAPI route participant H as Your function C->>S: POST /api/v1/discovery S->>M: request M->>M: set trace id M->>F: call_next(request) F->>F: validate body and headers, run dependencies alt valid input F->>H: call with typed arguments H-->>F: return value F->>F: filter through response_model else invalid input F->>F: error handler builds the 422 body end F-->>M: response M->>M: add X-Trace-Id header M-->>S: response S-->>C: HTTP response

    How to read this: your function sits in the middle of the sequence, and a request that fails validation reaches the error handler instead of it while still passing through the middleware on the way out.

    The middleware, in the FastAPI docs' words, "works with every request before it is processed by any specific path operation", and with every response before it is returned. The capstone's trace-id middleware uses that to stamp every reply.

    Verified: capstone/fde-product-discovery-copilot/app/main.py

        @app.middleware("http")
        async def add_trace_id(request: Request, call_next):  # type: ignore[no-untyped-def]
            request.state.trace_id = uuid.uuid4().hex[:16]
            response = await call_next(request)
            response.headers["X-Trace-Id"] = request.state.trace_id
            return response

    A small app in this lesson records the order. The test shows middleware first, then the dependency, then the handler, then middleware again. For an invalid body it also shows the dependency running and the handler never running; that is what FastAPI 0.162.0 did when this lesson was written, so treat it as an observation, not a promise.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L3/test_m1_l3_service.py

    def test_request_lifecycle_order_on_success():
        events: list[str] = []
        r = TestClient(create_lifecycle_app(events)).post("/echo", json={"n": 3})
        assert r.status_code == 200 and r.json() == {"n": 3}
        assert events == ["middleware: before", "dependency", "handler", "middleware: after"]

    Docs for free

    Run the service and open /docs. The FastAPI tutorial says that page is the "automatic interactive API documentation (provided by Swagger UI)", with /redoc as an alternative and the raw description at /openapi.json. OpenAPI is "a specification that dictates how to define a schema of your API". Your Pydantic models become the request and response schemas in it.

    The practical effect for a forward deployed engineer: a customer's front-end team can read the contract and try a call without a separate document, and it is always the contract the code enforces. The test in the build section asserts that /openapi.json lists the route and the Product schema.

    Spring Boot and FastAPI side by side

    Concern Spring Boot FastAPI
    Map a request to code @RequestMapping; @GetMapping and @PostMapping are composed annotations of it @app.get, @api.post decorators
    Read a JSON body @RequestBody deserializes it; add @Valid to apply Bean Validation A parameter typed as a Pydantic model: parsed and validated
    Default status for a failed body validation 400, via MethodArgumentNotValidException 422 in the capstone and in this lesson's tests
    API documentation Configured separately Generated: /docs, /redoc, /openapi.json
    Provide shared objects Beans in the container; constructor injection, and @Autowired is optional with one constructor Arguments to create_app, app.state, or Depends
    Test with a substitute Register a substitute bean in the test context app.dependency_overrides

    Depends compared with Spring beans

    Question Spring bean FastAPI dependency
    What is it? A class registered in the container Any function you pass to Depends
    How is it found? By type, with names or qualifiers to disambiguate You name the function in the parameter
    How long does it live? One instance per container by default: "The singleton scope is the default scope in Spring" It is called for each request, and the result is cached within that request
    Can it need other things? Its constructor takes other beans The function's own parameters can be dependencies
    Per-request object Request scope, one instance per HTTP request The normal behaviour

    What the FastAPI docs say:

    • Dependency injection is a way for your code to "declare things that it requires to work and use", leaving FastAPI to provide them.
    • For a dependency declared more than once for the same path operation, FastAPI "will know to call that sub-dependency only once per request" and caches the value (sub-dependencies page).

    Because a dependency function runs for every request, do not build an expensive object inside it. The catalog, with its search index, is built once and handed over: the capstone does that in create_app, and this lesson's service reads it from app.state inside a tiny dependency.

    Verified: capstone/fde-product-discovery-copilot/app/main.py

    def create_app(settings: Settings | None = None, services: Services | None = None) -> FastAPI:
        settings = settings or Settings.from_env()
        configure_logging()
        app = FastAPI(title="Product Discovery Copilot", version="1.0.0")
        app.state.services = services or Services.build(settings)
        app.state.idempotency = IdempotencyStore()
        install_error_handlers(app)
        # The Vite dev server proxies /api, so CORS is only needed if you call the API from another origin.
        app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"], allow_methods=["*"], allow_headers=["*"])

    The capstone does not use Depends for authentication. A local helper who(authorization) calls identity_from_header. That works; the lesson's service shows the Depends form, which has one advantage: the route's tests can replace it.

    NOTE

    Plain def routes, as the capstone uses, run in a threadpool: the FastAPI docs say a path operation declared with normal def "is run in an external threadpool that is then awaited". Their rule for unsure cases is "use normal def". The next lesson covers async def.

    Worked example

    Assumptions: two illustrative products, one public and one partner-only, in a catalog the service reads through a dependency; the output below describes what the tests assert.

    The service has one route, GET /products/{sku}, with a response_model of Product. A dependency provides the catalog, another works out which visibilities the caller may see: no header means anonymous, a valid token gives the role's visibilities, and a bad token is a 401, never silently anonymous.

    Request Result
    GET /products/LUM-VS-100, no header 200, the public product
    GET /products/LUM-VS-900, no header 404 (partner-only is invisible, not forbidden)
    GET /products/LUM-VS-900, partner token 200
    GET /products/not-a-sku 422, Invalid input: sku, before the function runs
    Bearer token nonsense 401 unauthorized

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L3/m1_l3_service.py

    def get_catalog(request: Request) -> Catalog:
        return request.app.state.catalog
    
    
    def get_visibilities(request: Request, authorization: Annotated[str | None, Header()] = None) -> frozenset[str]:
        """No header means an anonymous visitor. A bad token is an error (AuthError), not anonymous."""
        if not authorization:
            return allowed_visibilities(Identity("anonymous", "public", "public"))
        return allowed_visibilities(identity_from_header(authorization, request.app.state.secret))
    
    
    CatalogDep = Annotated[Catalog, Depends(get_catalog)]
    VisibilityDep = Annotated[frozenset[str], Depends(get_visibilities)]
    SkuPath = Annotated[str, Path(pattern=r"^LUM-[A-Z]{2}-\d{3}
    quot;
    )]

    The path rule SkuPath reuses the same SKU pattern as the Product model, so a malformed SKU is rejected at the edge. Hiding a partner-only product behind a 404 is a design choice: it does not confirm that the product exists.

    In the interview: "How does dependency injection in FastAPI compare with Spring?" A good answer: "Spring wires beans by type in a container, usually as singletons. FastAPI has no container: a route names a function with Depends, and it runs per request with its result cached for that request. For shared expensive objects I build them once at start-up and hand them over. In tests I replace a dependency with dependency_overrides, which is the lighter version of swapping a bean."

    Build it

    The lesson service is built from the declarations above.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L3/m1_l3_service.py

    def create_catalog_app(catalog: Catalog, secret: str) -> FastAPI:
        app = FastAPI(title="Catalog service", version="0.1.0")
        app.state.catalog = catalog
        app.state.secret = secret
        install_error_handlers(app)
    
        @app.get("/products/{sku}", response_model=Product)
        def get_product(sku: SkuPath, catalog: CatalogDep, visible: VisibilityDep) -> Product:
            product = catalog.get(sku, visible)
            if product is None:
                raise NotFoundError("No such product.")
            return product
    
        return app

    The test swaps the catalog without touching the route, and shows that the original comes back after the override is reset.

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L3/test_m1_l3_service.py

    def test_dependency_overrides_swap_the_catalog_without_touching_the_handler():
        app = create_catalog_app(make_catalog(), SECRET)
        only_partner = Catalog([Product.model_validate(PARTNER_SKU)])
        app.dependency_overrides[get_catalog] = lambda: only_partner
        c = TestClient(app)
        assert c.get("/products/LUM-VS-100").status_code == 404  # not in the substitute catalog
        assert c.get("/products/LUM-VS-900", headers=bearer("partner")).status_code == 200
        app.dependency_overrides = {}
        assert c.get("/products/LUM-VS-100").status_code == 200

    Run ./.venv/Scripts/python -m pytest lessons/M1-L3 -q from the capstone folder, then start the capstone itself with ./.venv/Scripts/python -m uvicorn app.main:app --port 8000 and open http://127.0.0.1:8000/docs. Find POST /api/v1/discovery and read the request and response schemas.

    Exercise

    Add GET /products to the lesson service. It lists products the caller may see, with optional category and max_price query parameters.

    Acceptance criteria:

    • It reuses CatalogDep and VisibilityDep; it does not read the token itself.
    • category accepts only the five categories and max_price must be greater than 0, both rejected with 422 before your function runs.
    • Anonymous callers never see partner-only products, and a partner token does.
    • The response is typed as a list of Product.

    Done when your three tests pass: filters work, partner visibility works, and bad filters return 422. A reference is in lessons/M1-L3/m1_l3_solution_listing.py; write yours first.

    Hint 1: the catalog already filters

    Catalog.search takes a text query, the visibilities, a category and a maximum price, and applies them itself. Your route only has to pass them through; an empty query is allowed.

    Hint 2: validation lives in the types

    A parameter typed with the Category type from app.models is rejected for any other string, and Query(gt=0) rejects zero and below. You write no if for either.

    Common mistakes

    • Doing the work inside a dependency. It runs for every request. Build expensive objects once and return them.
    • A dependency defined inside another function. With from __future__ import annotations, FastAPI cannot resolve the name in the annotation. Observed with FastAPI 0.162.0: the parameter became a required query parameter and the call returned 422. Define dependencies at module level.
    • Returning a model that has fields you meant to keep private. Set response_model to the public shape so FastAPI filters the output.
    • Leaving error shapes to the defaults. Clients then parse two formats. Install one handler set, as the capstone does.
    • Forgetting that the docs page is reachable. /docs lists every route. Decide deliberately, before exposing a service beyond your team, who may see it.
    • Mixing up 404 and 403 by accident. Decide whether a hidden resource should reveal that it exists, and test both paths.

    Knowledge check

    1. Where does FastAPI get the documentation at `/docs` from?

    From the same declarations the code uses: route decorators, parameter types and Pydantic models. FastAPI builds an OpenAPI description from them, and Swagger UI renders it. Because the schema comes from the code that enforces the rules, the docs cannot drift from the actual contract the way a hand-written document can.

    2. A request body fails validation. Does the route function run, and does middleware still see the response?

    The function does not run. FastAPI answers with the error handler's response, and the middleware still wraps it, which is why the capstone's X-Trace-Id header is on a 422 as well. The test for the two-character request asserts that the header and the body trace id match.

    3. Name two differences between a Spring singleton bean and a FastAPI dependency.

    A Spring bean is one shared instance per container by default, found by type. A FastAPI dependency is a function you name explicitly, called for each request with the value cached only within that request. So shared state must be built once outside the dependency, or the cost repeats on every call.

    4. Why is `app.dependency_overrides` useful in tests?

    It replaces a dependency function with another for the whole app, so a test can supply a small catalog or a fixed identity without a real token or data file. The route code is unchanged. Reset the dictionary afterwards so one test does not affect the next.

    5. Why does the capstone return 404 for a partner-only product requested by a public caller?

    A 403 would confirm that the product exists, which is itself information a partner-only listing may want to protect. A 404 gives the same answer as for a product that does not exist. The tests assert this for anonymous and public callers and a 200 for the partner role.

    You can now

    • Read a FastAPI route and say where each parameter comes from.
    • Explain the order of middleware, validation, dependencies and your function.
    • Compare Depends with Spring beans on lifetime, discovery and testing.
    • Build a typed route with a dependency and test it with an override.
    • Use /docs and /openapi.json to confirm the contract you are serving.

    Further reading

    Last verified: 2026-11-02

    Next

    Async, HTTP clients, timeouts and retries

    Enroll to keep reading

    Lesson 6 of 9 · Forward Deployed Engineer: Python and FastAPI Foundations

    Build a FastAPI service: typed request and response models, automatic docs, Depends compared with Spring beans, and the capstone's create_app and routes.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Python and FastAPI Foundations, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments