Backend / Web frameworks / FastAPI / 08_project_structure.md

FastAPI project structure

Updated 5 interview angles 6 min read source
On this page5
  1. The single file
  2. The documented layout
  3. What the docs deliberately leave out
  4. Related
  5. Interview angle

FastAPI project structure

If you are asked “what are the two types of FastAPI project structure”, the honest answer is that the official documentation does not define two types. That framing is community shorthand. The words monolithic, flat, layered and feature-based appear nowhere in the docs, and neither does the “5-10 endpoints” threshold people quote alongside them.

What the docs actually contain is one implicit default and one documented layout:

Form Where in the docs Status
Single main.py Tutorial, from First Steps Never named as a structure
app/ package + APIRouter Bigger Applications The one canonical layout

Everything past that — services/, repositories/, domain/, hexagonal, feature slices — is convention with no official backing. Saying so is a better answer than picking one of two invented categories, because it shows you know where the framework stops and architecture begins.

The single file

Every tutorial example from First Steps through SQL Databases lives in one file. The docs never call it a structure or give it rules:

python
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

The Bigger Applications page opens by observing that a real application rarely fits in a single file. So this is the thing you outgrow, not a pattern you choose.

The documented layout

text
.
├── app
│   ├── __init__.py
│   ├── main.py
│   ├── dependencies.py
│   ├── routers
│   │   ├── __init__.py
│   │   ├── items.py
│   │   └── users.py
│   └── internal
│       ├── __init__.py
│       └── admin.py

What it is organised by

Read this carefully, because it is the part people get wrong: HTTP resource (users, items), plus a visibility split between public routers/ and internal/. It is not organised by architectural layer. There is no services/, no repositories/, no schemas/ versus models/ split. Shared dependencies sit in one dependencies.py at the package root.

Every directory carries an __init__.py. That is what makes app a package and app.routers.items an importable submodule.

APIRouter is a mini FastAPI

The docs describe APIRouter as supporting all the same options as the FastAPI class — same parameters, responses, dependencies and tags. If you know Flask, it is the equivalent of a Blueprint.

Hoist the repeated configuration onto the router rather than onto every path operation:

python
from fastapi import APIRouter, Depends, HTTPException

from ..dependencies import get_token_header

router = APIRouter(
    prefix="/items",
    tags=["items"],
    dependencies=[Depends(get_token_header)],
    responses={404: {"description": "Not found"}},
)


@router.get("/{item_id}")
async def read_item(item_id: str):
    if item_id not in fake_items_db:
        raise HTTPException(404, "Item not found")
    return {"item_id": item_id}

The four router parameters

Parameter What it does
prefix Prepended to every path. Leading /, no trailing /
tags Applied to all operations; groups them in the OpenAPI docs
responses Extra documented responses merged into each operation
dependencies Run on every request to any operation in the router

Per-operation additions still compose: a path operation that adds tags=["custom"] ends up with both items and custom.

dependencies on the router is the idiomatic way to require auth for a whole group without repeating it. Order of execution: router dependencies, then decorator dependencies, then normal parameter dependencies — and as with decorator dependencies, no value is passed into the function.

Relative imports are where people trip

The docs give this a section of its own, which tells you how often it bites. From app/routers/items.py:

Prefix Resolves to Works
.dependencies app/routers/dependencies.py no, absent
..dependencies app/dependencies.py yes
...dependencies above app/ no, no parent

One dot is “this module’s package”, two is “the parent package”, three is a level that does not exist here.

The main module

python
from fastapi import Depends, FastAPI

from .dependencies import get_query_token
from .internal import admin
from .routers import items, users

app = FastAPI(dependencies=[Depends(get_query_token)])

app.include_router(users.router)
app.include_router(items.router)
app.include_router(
    admin.router,
    prefix="/admin",
    tags=["admin"],
    responses={418: {"description": "I'm a teapot"}},
)

Gotcha: import the submodule, not the router variable. Both items.py and users.py name their router router, so from .routers.items import router followed by the same line for users silently overwrites the first.

Dependencies passed to FastAPI() are global and combine with each router’s own.

Configuring at inclusion time

The admin router in the docs models a real constraint: it is shared across several projects, so you cannot edit it to add a prefix or auth. Passing prefix, tags, dependencies and responses to include_router() applies them without mutating the original, so another project can include the same router behind a different authentication scheme.

Two further patterns, both flagged as advanced:

  1. The same router included twice under different prefixes — how you serve an API at both /api/v1 and /api/latest.
  2. Nested routersrouter.include_router(other), which is the mechanism behind the common app/api/v1/__init__.py aggregator.

Two technical points worth repeating in an interview, because they sound wrong until you know them:

  • Routers are not mounted and are not isolated. That is deliberate — it is what keeps their operations in the OpenAPI schema. FastAPI keeps the original routers active and merges prefixes, dependencies, tags and responses when handling requests and generating OpenAPI.
  • Including routers adds no per-request overhead; the docs say so outright.

Do not mutate router.routes after inclusion — it is a lower-level tree that may hold both route definitions and included routers, not a flat list.

Pointing the CLI at it

With app living in app/main.py, declare the entrypoint once:

toml
[tool.fastapi]
entrypoint = "app.main:app"

Equivalent to from app.main import app. You can pass the path manually (fastapi dev app/main.py), but then you must remember it every time and other tooling may not find the app.

What the docs deliberately leave out

There is no official guidance on where business logic lives, where persistence lives, whether Pydantic schemas are separated from ORM models, how to slice by domain rather than resource, or how to lay out settings and migrations.

The closest thing to an opinionated end-to-end answer is the Full Stack FastAPI Template, which the docs present as a customizable starting point rather than a prescription.

So the practical position: take app/ + routers/ + dependencies.py + include_router() as the sanctioned baseline, and treat any layering on top of it as your own architectural decision. The two community references people cite are Netflix’s dispatch (layered) and zhanymkanov/fastapi-best-practices (domain-based). See Architecture & design for the reasoning behind those choices.

Interview angle 5

  • “What are the two types of FastAPI project structure?” - the premise is community shorthand, not documentation. The docs show a single-file default and exactly one multi-file layout: an app/ package with routers/, a shared dependencies.py, and include_router(). Flat versus layered is a distinction teams invented; saying so is stronger than picking one.
  • “How do you split a FastAPI app across files?” - APIRouter, which the docs call a mini FastAPI and which maps to a Flask Blueprint. Hoist prefix, tags, responses and dependencies onto the router so they are declared once rather than per operation.
  • “How do you require auth for a whole group of endpoints?” - dependencies=[Depends(...)] on the APIRouter, or on include_router() when you do not own the router. Router dependencies run before decorator dependencies, and neither passes a value into the path operation.
  • “Does including a router cost anything at request time?” - no. Routers are not mounted and not isolated; FastAPI keeps the original path operations active and merges the metadata, which is exactly why they still appear in the OpenAPI schema.
  • “Where do services and repositories go?” - nowhere the docs specify. That is the point at which FastAPI stops having an opinion and you are making an architecture decision, so justify it by team size and testing needs rather than by citing the framework.