Project Structure & Layered Architecture

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on 2026-10-02. FastAPI supports Python 3.10 through 3.14; the code on this page was executed on Python 3.13. A single main.py is fine for a first endpoint and a bad place to keep a real service. This page shows how the Bookshelf API leaves that file: first the mechanics FastAPI gives you for splitting an application (APIRouter, include_router, packages and relative imports), then an architecture on top of them — web, service and data layers, with dependency injection wiring the layers together so that tests can swap the database for a fake without touching an environment variable. Every file shown below belongs to one working project that was run with TestClient and with the fastapi dev command; the test run is quoted in the testing section.

Why Split an Application, and Along Which Lines

FastAPI itself imposes no layout: an application is any FastAPI instance that can be imported. Two separate questions follow once the code outgrows one file, and it helps to keep them apart:

  • How do I spread path operations over several files? This is a framework question. The answer is APIRouter, described in the next sections. If you come from Flask, an APIRouter plays the role of a blueprint.

  • Where does each kind of logic live? This is a design question that FastAPI deliberately leaves to you. The answer used on this page is the classic three layers — web, service and data — with the dependencies pointing inwards. The general reasoning, independent of Python, is in Structuring a DDD Microservice’s Code, and the repository pattern in Repositories, Factories, and Domain Services; this page shows how the same ideas look in a FastAPI project.

Package Layout and Relative Imports

The official bigger-applications tutorial arranges an application as a Python package with one subpackage for routers. Applied to the Bookshelf API, the layout that grows out of it is:

The Bookshelf package
bookshelf/
├── pyproject.toml
├── app/
│   ├── __init__.py
│   ├── main.py            # creates the FastAPI app, includes the routers
│   ├── settings.py        # pydantic-settings configuration
│   ├── dependencies.py    # Depends() providers and Annotated aliases
│   ├── db.py              # data layer: SQLite adapters and Unit of Work
│   ├── models/            # Pydantic schemas shared by all layers
│   │   ├── __init__.py
│   │   └── book.py
│   ├── routers/           # web layer: one APIRouter per resource
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── books.py
│   │   └── reviews.py
│   └── services/          # service layer: use cases, errors, ports
│       ├── __init__.py
│       ├── books.py
│       ├── errors.py
│       └── ports.py
└── tests/
    ├── __init__.py
    ├── conftest.py
    ├── fakes.py
    ├── test_api.py
    └── test_service.py

Each directory with an __init__.py is a Python package, and each .py file inside is a module; that is what makes import app.routers.books work. The file may be empty. Without it, a directory can still be imported as an implicit namespace package, but tools, test runners and the FastAPI CLI behave more predictably with explicit packages, and the CLI uses __init__.py files to decide which directory to put on the Python path.

Relative and absolute imports

Inside the package, modules import each other either with an absolute import (from app.models import Book) or with a relative one, where each leading dot climbs one level from the importing module’s own package:

Relative imports from app/routers/books.py
from ..dependencies import BookServiceDep   # .. = the parent package, app
from ..models import Book, BookCreate       # app/models/__init__.py
from . import reviews                        # . = this package, app.routers
  • One dot (.) is the package the module lives in (app.routers). from .dependencies import x would look for app/routers/dependencies.py, which does not exist.

  • Two dots (..) are the parent package (app), where dependencies.py lives.

  • One dot too many leaves the top-level package and Python raises an error. These are the real messages from the Bookshelf layout with the wrong number of dots:

$ python -c "import app.routers.items"      # from ...dependencies import dep
ImportError: attempted relative import beyond top-level package
$ python -c "import app.routers.items2"     # from .dependencies import dep
ModuleNotFoundError: No module named 'app.routers.dependencies'

Source: Python: Modules

Relative imports only work inside a package that is imported as such: running python app/routers/books.py directly fails with ImportError: attempted relative import with no known parent package, because the file is then the top-level __main__ module. Run the application through the fastapi command or Uvicorn, which import it by name. Absolute imports are equally valid and are what the Full Stack FastAPI Template uses; choose one style and keep it.

Name collisions

Import the submodule, not the variable inside it. Every router module calls its router router, so from .routers.items import router followed by from .routers.users import router silently makes the second router replace the first. Importing the modules (from .routers import books, admin) and writing books.router, admin.router avoids the clash, and is what main.py below does.

APIRouter: a Mini FastAPI

An APIRouter is declared like a FastAPI application and has the same path operation decorators (get, post, put, patch, delete, …​), but it is not an ASGI application by itself: you cannot run it, only include it in an application or in another router. A router can carry settings that apply to every path operation declared on it:

The books router with a prefix, a tag, and a shared response
from fastapi import APIRouter, status

from ..dependencies import BookServiceDep
from ..models import Book, BookCreate
from . import reviews

router = APIRouter(
    prefix="/books",
    tags=["books"],
    responses={404: {"description": "Book not found"}},
)
router.include_router(reviews.router)


@router.get("/")
def list_books(service: BookServiceDep) -> list[Book]:
    return service.list_books()


@router.post(
    "/",
    status_code=status.HTTP_201_CREATED,
    responses={409: {"description": "ISBN already exists"}},
)
def create_book(data: BookCreate, service: BookServiceDep) -> Book:
    return service.add_book(data)


@router.get("/{book_id}")
def read_book(book_id: int, service: BookServiceDep) -> Book:
    return service.get_book(book_id)

The constructor parameters that matter day to day:

Parameter Effect

prefix

A string prepended to every path of the router. It must start with / and must not end with /; the path operations then start with / themselves. Both rules are enforced with an AssertionError when the router is created: A path prefix must not end with '/', as the routes will start with '/' and A path prefix must start with '/'.

tags

A list of tags added to every path operation. Swagger UI groups operations by tag, so this is how the interactive documentation gets its sections. A path operation may add its own tags; the lists are concatenated.

dependencies

Dependencies evaluated for every request to the router, as if each path operation had them in its decorator. Their return values are discarded, so use them for checks such as authentication or rate limits. See Dependencies at Every Level.

responses

Extra OpenAPI responses (for example 404) merged into every path operation’s documentation. They document behaviour; they do not make the framework return those statuses.

default_response_class

The response class used by operations that do not set response_class.

deprecated, include_in_schema

Mark every operation as deprecated, or hide the router from the OpenAPI schema.

callbacks, generate_unique_id_function, route_class

OpenAPI callbacks for every operation, a function that computes operationId, and a custom APIRoute subclass.

A path of "" is legal when the router has a prefix (@router.get("") on a router with prefix="/books" serves /books), whereas a router with neither a prefix nor a path raises Prefix and path cannot be both empty. Declaring the path as "/" instead, as the example above does, makes the canonical URL end with a slash (/api/v1/books/) and makes Starlette redirect /api/v1/books to it; pick one convention per project.

include_router

A router does nothing until an application includes it. main.py is therefore short: it creates the application, includes the routers and registers cross-cutting handlers.

app/main.py
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse

from .dependencies import require_admin
from .routers import admin, books
from .services.errors import BookNotFound, DuplicateIsbn

app = FastAPI(title="Bookshelf API", version="0.1.0")

app.include_router(books.router, prefix="/api/v1")
app.include_router(
    admin.router,
    prefix="/admin",
    tags=["admin"],
    dependencies=[Depends(require_admin)],
    responses={403: {"description": "Admin token required"}},
)


@app.exception_handler(BookNotFound)
def book_not_found_handler(request: Request, exc: BookNotFound) -> JSONResponse:
    return JSONResponse(status_code=404, content={"detail": str(exc)})


@app.exception_handler(DuplicateIsbn)
def duplicate_isbn_handler(request: Request, exc: DuplicateIsbn) -> JSONResponse:
    return JSONResponse(status_code=409, content={"detail": str(exc)})

include_router accepts prefix, tags, dependencies, responses, deprecated, include_in_schema, default_response_class, callbacks and generate_unique_id_function. Passing them at inclusion time is how an application configures a router it does not own. Here admin.router is a module that several projects might share, so it declares nothing:

app/routers/admin.py
from fastapi import APIRouter

from ..dependencies import BookServiceDep

# Imagine this module is shared between projects: it declares no prefix,
# tags or dependencies. The including application decides.
router = APIRouter()


@router.get("/stats")
def stats(service: BookServiceDep) -> dict[str, int]:
    return {"books": len(service.list_books())}

and the application decides that it lives under /admin, is tagged admin, documents a 403, and is protected by the require_admin dependency. The original APIRouter object stays unmodified, so another project can include the same module with a different prefix and a different authentication method.

Because a request passes through the application, the inclusion, the router and the path operation, the dependencies declared at each level are all evaluated. The order was checked by recording a list in each dependency:

The order of dependencies and a router included twice
from fastapi import APIRouter, Depends, FastAPI
from fastapi.testclient import TestClient

order: list[str] = []


def app_dep() -> None:
    order.append("app")


def include_dep() -> None:
    order.append("include")


def router_dep() -> None:
    order.append("router")


def decorator_dep() -> None:
    order.append("decorator")


router = APIRouter(prefix="/books", dependencies=[Depends(router_dep)])
app = FastAPI(dependencies=[Depends(app_dep)])
app.include_router(router, prefix="/api/v1", dependencies=[Depends(include_dep)])
app.include_router(router, prefix="/api/latest")


@router.get("/ping", dependencies=[Depends(decorator_dep)])  # added after include_router
def ping() -> dict[str, str]:
    return {"ok": "yes"}


client = TestClient(app)
print(client.get("/api/v1/books/ping").json(), order)
print(client.get("/api/latest/books/ping").status_code)
print(sorted(app.openapi()["paths"]))
{'ok': 'yes'} ['app', 'include', 'router', 'decorator']
200
['/api/latest/books/ping', '/api/v1/books/ping']

What this run shows:

  • Order. Application-level dependencies run first, then those passed to include_router, then the router’s own, then the ones in the path operation decorator, and finally the ordinary parameters of the function.

  • Same router, several prefixes. One router can be included more than once, for example to expose /api/v1 and /api/latest without duplicating code. Both prefixes appear in the OpenAPI schema.

  • Routes added after inclusion. The ping operation was declared after the include_router calls and still appears under both prefixes. Since 0.137.0 (2026-06-14) FastAPI keeps the original router object instead of copying its routes at inclusion time, so ordering of declaration and inclusion no longer matters.

The routers are not mounted like a sub-application: they stay part of the same application, so their operations appear in the same /docs page and openapi.json. A mounted sub-application, with its own docs and its own middleware, is a different tool, covered in Sub-Applications, WSGI & GraphQL.

Nested Routers

A router can include another router, exactly as an application does with router.include_router(other_router). In the Bookshelf API a review only makes sense inside a book, so books.router includes reviews.router, whose own prefix is /{book_id}/reviews:

app/routers/reviews.py
from fastapi import APIRouter, status

from ..dependencies import BookServiceDep
from ..models import Review, ReviewCreate

router = APIRouter(prefix="/{book_id}/reviews", tags=["reviews"])


@router.get("/")
def list_reviews(book_id: int, service: BookServiceDep) -> list[Review]:
    return service.list_reviews(book_id)


@router.post("/", status_code=status.HTTP_201_CREATED)
def create_review(book_id: int, data: ReviewCreate, service: BookServiceDep) -> Review:
    return service.add_review(book_id, data)

The router.include_router(reviews.router) line in books.py plus the /api/v1 prefix given by main.py give the review operations the path /api/v1/books/{book_id}/reviews/, the tags books and reviews, and the 404 response of the parent. The generated OpenAPI paths from the real run were:

GET  /api/v1/books/{book_id}/reviews/  tags=['books', 'reviews']  responses=200, 404, 422
POST /api/v1/books/{book_id}/reviews/  tags=['books', 'reviews']  responses=201, 404, 422
GET  /api/v1/books/                    tags=['books']             responses=200, 404
POST /api/v1/books/                    tags=['books']             responses=201, 404, 409, 422
GET  /api/v1/books/{book_id}           tags=['books']             responses=200, 404, 422
GET  /admin/stats                      tags=['admin']             responses=200, 403, 422

The 422 responses are added automatically to operations that have parameters or a body. The 404 and 409 come from the responses arguments. A tag list such as ['books', 'reviews'] makes Swagger UI show the operation under both headings; use a single tag per router if you prefer one group per resource.

Nesting is useful for resources that are addressed through their parent. Avoid more than two levels: the path gets long and the layers of prefix, tags and dependencies become hard to follow. When inclusion order matters for matching (/books/me must be registered before /books/{book_id}), the same rule as in a single file applies: operations are tried in declaration order, and routers are tried in inclusion order.

The pyproject.toml Entrypoint

The fastapi command needs to know where the application object is. Without configuration it auto-discovers candidates such as main.py and app/main.py (the layout here is found this way: the CLI prints app.main:app (auto-discovered …​) and suggests configuring an entrypoint). Auto-discovery guesses, so the recommended way is to declare the entrypoint once in pyproject.toml:

pyproject.toml
[project]
name = "bookshelf"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["fastapi[standard]>=0.142,<0.143"]

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

The value is package.module:variable — the same string you would give Uvicorn — and is equivalent to from app.main import app. Running fastapi dev with no arguments from the project directory printed this first line, so the entrypoint was found:

$ fastapi dev
 🐍 Using import string: app.main:app

and a request to the running server with a database configured through the environment worked:

curl -s -X POST http://127.0.0.1:37411/api/v1/books/ \
  -H 'Content-Type: application/json' \
  -d '{"title":"Dune","author":"Frank Herbert","isbn":"9780441013593"}'
{"title":"Dune","author":"Frank Herbert","isbn":"9780441013593","id":1}

You can still pass a path (fastapi dev app/main.py) or --entrypoint on the command line, but then every command needs it and other tools — the VS Code extension, FastAPI Cloud — cannot discover the application. Getting-started covers the CLI in more detail: How the App Is Found.

Web, Service and Data Layers

Splitting files is not yet an architecture. The usual way to keep a growing FastAPI service testable is to give each file one of three jobs and to let dependencies point only one way:

Layer Responsibility In the Bookshelf API

Web

Translate HTTP into function calls and back: routing, parameter parsing and validation, status codes, error mapping, authentication checks. Knows about Request, HTTPException and OpenAPI. Knows nothing about SQL.

app/main.py, app/routers/, and the providers in app/dependencies.py.

Service

The use cases of the application: add a book, reject a duplicate ISBN, add a review to an existing book. Plain Python and domain errors; knows neither HTTP nor SQL.

app/services/books.py, errors.py and ports.py.

Data

Persistence: how books and reviews are stored and found. Knows SQL (or Redis, or an HTTP API) and nothing about HTTP.

app/db.py; the in-memory fake in tests/fakes.py.

The models package sits beside the layers rather than inside one: the Pydantic schemas are the vocabulary all three share, and they are the types FastAPI validates and documents.

A diagram with three boxes in a row, left to right: the web layer (app/routers and main.py), the service layer (app/services, with the ports) and the data layer (app/db.py and tests/fakes.py), joined by arrows labelled calls and calls via port. A dashed bar above them, the dependency wiring in app/dependencies.py, has dashed arrows down to each layer. A bar below them, the models package, is joined to each layer by dashed lines

The models are ordinary Pydantic v2 classes:

app/models/book.py
from pydantic import BaseModel, Field


class BookCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    author: str = Field(min_length=1, max_length=100)
    isbn: str = Field(pattern=r"^\d{13}$")


class Book(BookCreate):
    id: int


class ReviewCreate(BaseModel):
    rating: int = Field(ge=1, le=5)
    text: str = Field(default="", max_length=2000)


class Review(ReviewCreate):
    id: int
    book_id: int

The BookCreate / Book split (input without an id, output with one) is covered in Request Body & Models and Response Models & Return Types.

A path operation in the web layer then only translates and delegates:

@router.get("/{book_id}")
def read_book(book_id: int, service: BookServiceDep) -> Book:
    return service.get_book(book_id)

The service raises domain errors that carry no HTTP vocabulary:

app/services/errors.py
class BookshelfError(Exception):
    """Base class for domain errors; knows nothing about HTTP."""


class BookNotFound(BookshelfError):
    def __init__(self, book_id: int) -> None:
        super().__init__(f"Book {book_id} not found")
        self.book_id = book_id


class DuplicateIsbn(BookshelfError):
    def __init__(self, isbn: str) -> None:
        super().__init__(f"A book with ISBN {isbn} already exists")
        self.isbn = isbn

and the web layer maps them to responses in one place, with exception handlers in main.py (shown above): BookNotFound becomes 404 and DuplicateIsbn becomes 409. The service can therefore be reused from a command-line script or a background worker without importing FastAPI, and the mapping to status codes can change without touching the use cases. More on handlers in Status Codes & Error Handling.

Ports and Adapters, Wired by Dependency Injection

Hexagonal architecture (ports and adapters) says the application core defines the ports it needs — interfaces for "store books", "send mail" — and the outside world supplies adapters that implement them. In Python the natural port is a typing.Protocol: a class is accepted when it has the right methods, no inheritance needed. The service layer owns these definitions:

app/services/ports.py
from types import TracebackType
from typing import Protocol, Self

from ..models import Book, BookCreate, Review, ReviewCreate


class BookRepository(Protocol):
    def add(self, data: BookCreate) -> Book: ...
    def get(self, book_id: int) -> Book | None: ...
    def get_by_isbn(self, isbn: str) -> Book | None: ...
    def list(self) -> list[Book]: ...


class ReviewRepository(Protocol):
    def add(self, book_id: int, data: ReviewCreate) -> Review: ...
    def list_for_book(self, book_id: int) -> list[Review]: ...


class UnitOfWork(Protocol):
    books: BookRepository
    reviews: ReviewRepository

    def __enter__(self) -> Self: ...
    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None: ...
    def commit(self) -> None: ...
    def rollback(self) -> None: ...

The service depends on the UnitOfWork port only:

app/services/books.py
from ..models import Book, BookCreate, Review, ReviewCreate
from .errors import BookNotFound, DuplicateIsbn
from .ports import UnitOfWork


class BookService:
    """Use cases. Depends on the UnitOfWork port, never on a database."""

    def __init__(self, uow: UnitOfWork) -> None:
        self.uow = uow

    def add_book(self, data: BookCreate) -> Book:
        with self.uow:
            if self.uow.books.get_by_isbn(data.isbn) is not None:
                raise DuplicateIsbn(data.isbn)
            book = self.uow.books.add(data)
            self.uow.commit()
            return book

    def get_book(self, book_id: int) -> Book:
        with self.uow:
            book = self.uow.books.get(book_id)
            if book is None:
                raise BookNotFound(book_id)
            return book

    def list_books(self) -> list[Book]:
        with self.uow:
            return self.uow.books.list()

    def add_review(self, book_id: int, data: ReviewCreate) -> Review:
        with self.uow:
            if self.uow.books.get(book_id) is None:
                raise BookNotFound(book_id)
            review = self.uow.reviews.add(book_id, data)
            self.uow.commit()
            return review

    def list_reviews(self, book_id: int) -> list[Review]:
        with self.uow:
            if self.uow.books.get(book_id) is None:
                raise BookNotFound(book_id)
            return self.uow.reviews.list_for_book(book_id)

Which adapter the service receives is decided in one place, app/dependencies.py, with ordinary FastAPI dependencies:

app/dependencies.py
import secrets
from typing import Annotated

from fastapi import Depends, Header, HTTPException, status

from .db import SqliteUnitOfWork
from .services.books import BookService
from .services.ports import UnitOfWork
from .settings import Settings, get_settings

SettingsDep = Annotated[Settings, Depends(get_settings)]


def get_unit_of_work(settings: SettingsDep) -> UnitOfWork:
    return SqliteUnitOfWork(settings.database_path)


UnitOfWorkDep = Annotated[UnitOfWork, Depends(get_unit_of_work)]


def get_book_service(uow: UnitOfWorkDep) -> BookService:
    return BookService(uow)


BookServiceDep = Annotated[BookService, Depends(get_book_service)]


def require_admin(
    settings: SettingsDep,
    x_admin_token: Annotated[str | None, Header()] = None,
) -> None:
    expected = settings.admin_token
    if (
        expected is None
        or x_admin_token is None
        or not secrets.compare_digest(
            x_admin_token.encode(), expected.get_secret_value().encode()
        )
    ):
        raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin token required")

The chain is get_settings → get_unit_of_work → get_book_service. Each router parameter is declared as service: BookServiceDep, an Annotated alias, so the path operations never mention SQLite. FastAPI resolves the chain per request, builds the objects, and caches them for the duration of that request.

DI instead of environment-variable module switching

A common way to substitute a database in tests is an environment variable that selects which module to import: the code reads a variable at import time and imports either a fake or a real data module. It works, but it has real costs: the choice is made at import time, so one process cannot use both; the switch leaks into production code; the type checker sees only one branch; and a test that forgets to set the variable talks to the real database.

Dependency injection removes the switch. The production code always asks for get_unit_of_work; a test replaces that one provider for the duration of the test:

app.dependency_overrides[get_unit_of_work] = lambda: fake_uow

Nothing is imported conditionally, the real adapter is still importable, and the replacement is scoped to a test (clear app.dependency_overrides afterwards, as the fixture below does). Environment variables remain useful for configuration — the database path, as in settings.py — but not for choosing code. The settings class is described in Settings, Lifespan & Background Tasks.

app/settings.py
from functools import lru_cache

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="BOOKSHELF_")

    database_path: str = "bookshelf.db"
    admin_token: SecretStr | None = None


@lru_cache
def get_settings() -> Settings:
    return Settings()

The admin token is optional and read from the environment (BOOKSHELF_ADMIN_TOKEN); no secret is written in the code, and SecretStr keeps it out of reprs and logs. With no token configured, require_admin rejects every request.

Repository and Unit of Work

A repository hides storage behind a collection-like interface: add, get, list. The service asks for a book by ISBN and never learns whether that is a SQL query, a dictionary or a call to another service. A Unit of Work groups the repositories that must change together and owns the transaction: nothing is persisted until commit(), and leaving the block without committing rolls back.

The SQLite adapter implements both ports with the standard-library sqlite3 module, which keeps the example free of an ORM. The same shape works with SQLAlchemy sessions or SQLModel, which the SQL Databases with SQLModel page uses; there the unit of work wraps a Session.

app/db.py
import sqlite3
from types import TracebackType
from typing import Self

from .models import Book, BookCreate, Review, ReviewCreate

SCHEMA = """
CREATE TABLE IF NOT EXISTS books (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    isbn TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS reviews (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    book_id INTEGER NOT NULL REFERENCES books(id),
    rating INTEGER NOT NULL,
    text TEXT NOT NULL
);
"""


def create_schema(path: str) -> None:
    with sqlite3.connect(path) as conn:
        conn.executescript(SCHEMA)


class SqliteBookRepository:
    def __init__(self, conn: sqlite3.Connection) -> None:
        self.conn = conn

    def add(self, data: BookCreate) -> Book:
        cur = self.conn.execute(
            "INSERT INTO books (title, author, isbn) VALUES (?, ?, ?)",
            (data.title, data.author, data.isbn),
        )
        return Book(id=cur.lastrowid, **data.model_dump())

    def _row(self, row: sqlite3.Row | None) -> Book | None:
        return None if row is None else Book(**dict(row))

    def get(self, book_id: int) -> Book | None:
        return self._row(
            self.conn.execute("SELECT * FROM books WHERE id = ?", (book_id,)).fetchone()
        )

    def get_by_isbn(self, isbn: str) -> Book | None:
        return self._row(
            self.conn.execute("SELECT * FROM books WHERE isbn = ?", (isbn,)).fetchone()
        )

    def list(self) -> list[Book]:
        rows = self.conn.execute("SELECT * FROM books ORDER BY id").fetchall()
        return [Book(**dict(row)) for row in rows]


class SqliteReviewRepository:
    def __init__(self, conn: sqlite3.Connection) -> None:
        self.conn = conn

    def add(self, book_id: int, data: ReviewCreate) -> Review:
        cur = self.conn.execute(
            "INSERT INTO reviews (book_id, rating, text) VALUES (?, ?, ?)",
            (book_id, data.rating, data.text),
        )
        return Review(id=cur.lastrowid, book_id=book_id, **data.model_dump())

    def list_for_book(self, book_id: int) -> list[Review]:
        rows = self.conn.execute(
            "SELECT * FROM reviews WHERE book_id = ? ORDER BY id", (book_id,)
        ).fetchall()
        return [Review(**dict(row)) for row in rows]


class SqliteUnitOfWork:
    """One connection and one transaction per use case."""

    def __init__(self, path: str) -> None:
        self.path = path

    def __enter__(self) -> Self:
        self.conn = sqlite3.connect(self.path)
        self.conn.row_factory = sqlite3.Row
        self.books = SqliteBookRepository(self.conn)
        self.reviews = SqliteReviewRepository(self.conn)
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        self.rollback()  # no-op after commit(); undoes uncommitted work
        self.conn.close()

    def commit(self) -> None:
        self.conn.commit()

    def rollback(self) -> None:
        self.conn.rollback()

Points that matter in this adapter:

  • The connection is opened in __enter__ and closed in __exit__, so each use case gets its own connection and transaction. FastAPI runs plain def path operations in a worker thread pool, and a sqlite3 connection must not be shared across threads; creating it inside the use case sidesteps that.

  • __exit__ always calls rollback(). After a commit() it does nothing; after an exception, or a use case that returned without committing, it undoes the work.

  • The service performs the read ("is this ISBN taken?") and the write in one with self.uow: block, so both belong to one transaction. A race between two requests is still possible with this check alone; the UNIQUE constraint on isbn is the real guard, and a production adapter would translate the constraint violation into DuplicateIsbn.

  • The service functions are plain def, so FastAPI runs the path operations in the thread pool and blocking sqlite3 calls do not stall the event loop. With an async driver you would make the ports async def and the path operations async def (see Async & Concurrency).

Calling the application with the real adapter, using TestClient and a temporary database file configured through the environment, gave these results (the first request creates the book, the second repeats it, the last two exercise the admin dependency):

201 {'title': 'Dune', 'author': 'Frank Herbert', 'isbn': '9780441013593', 'id': 1}
409 {'detail': 'A book with ISBN 9780441013593 already exists'}
201 {'rating': 5, 'text': 'Spice', 'id': 1, 'book_id': 1}
404 {'detail': 'Book 99 not found'}
403 {'detail': 'Admin token required'}
200 {'books': 1}

Fakes Versus Mocks

Both are test doubles; they answer different questions.

Fake Mock

What it is

A small working implementation of the port, simplified (state kept in a dictionary).

An object that records calls and returns what the test scripted, created for example with unittest.mock.create_autospec.

What the test asserts

The outcome: after POST, GET returns the book.

The interaction: the router called service.get_book(7) exactly once.

Brittleness

Low: refactoring the service’s internals does not break the test as long as behaviour is preserved.

Higher: the test is coupled to the exact calls; changing how the service talks to its collaborators breaks it.

Cost

Must be written and kept faithful to the real adapter.

Almost free.

Best for

Service and API tests; whole flows.

Isolating the web layer from the service; error paths that are hard to produce; checking that a side effect (an email) happened.

The fake for the Bookshelf API implements the same UnitOfWork port in memory:

tests/fakes.py
from types import TracebackType
from typing import Self

from app.models import Book, BookCreate, Review, ReviewCreate


class FakeBookRepository:
    def __init__(self) -> None:
        self.items: dict[int, Book] = {}

    def add(self, data: BookCreate) -> Book:
        book = Book(id=len(self.items) + 1, **data.model_dump())
        self.items[book.id] = book
        return book

    def get(self, book_id: int) -> Book | None:
        return self.items.get(book_id)

    def get_by_isbn(self, isbn: str) -> Book | None:
        return next((b for b in self.items.values() if b.isbn == isbn), None)

    def list(self) -> list[Book]:
        return list(self.items.values())


class FakeReviewRepository:
    def __init__(self) -> None:
        self.items: list[Review] = []

    def add(self, book_id: int, data: ReviewCreate) -> Review:
        review = Review(id=len(self.items) + 1, book_id=book_id, **data.model_dump())
        self.items.append(review)
        return review

    def list_for_book(self, book_id: int) -> list[Review]:
        return [r for r in self.items if r.book_id == book_id]


class FakeUnitOfWork:
    """Same port as SqliteUnitOfWork, state kept in memory."""

    def __init__(self) -> None:
        self.books = FakeBookRepository()
        self.reviews = FakeReviewRepository()
        self.committed = False

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        pass

    def commit(self) -> None:
        self.committed = True

    def rollback(self) -> None:
        pass

and a fixture parametrizes the service tests over both adapters, so the fake is verified against the real one (a contract test): if the SQLite adapter and the fake ever disagree, one of the two parametrized runs fails.

tests/conftest.py
from collections.abc import Iterator
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from app.db import SqliteUnitOfWork, create_schema
from app.dependencies import get_unit_of_work
from app.main import app
from app.services.ports import UnitOfWork

from .fakes import FakeUnitOfWork


@pytest.fixture
def fake_uow() -> FakeUnitOfWork:
    return FakeUnitOfWork()


@pytest.fixture(params=["fake", "sqlite"])
def uow(request: pytest.FixtureRequest, tmp_path: Path) -> UnitOfWork:
    """The same tests run against every adapter of the UnitOfWork port."""
    if request.param == "fake":
        return FakeUnitOfWork()
    path = str(tmp_path / "test.db")
    create_schema(path)
    return SqliteUnitOfWork(path)


@pytest.fixture
def client(fake_uow: FakeUnitOfWork) -> Iterator[TestClient]:
    app.dependency_overrides[get_unit_of_work] = lambda: fake_uow
    with TestClient(app) as test_client:
        yield test_client
    app.dependency_overrides.clear()
tests/test_service.py
import pytest

from app.models import BookCreate, ReviewCreate
from app.services.books import BookService
from app.services.errors import BookNotFound, DuplicateIsbn
from app.services.ports import UnitOfWork

DUNE = BookCreate(title="Dune", author="Frank Herbert", isbn="9780441013593")


def test_add_and_get(uow: UnitOfWork) -> None:
    service = BookService(uow)
    created = service.add_book(DUNE)
    assert service.get_book(created.id) == created


def test_duplicate_isbn_rejected(uow: UnitOfWork) -> None:
    service = BookService(uow)
    service.add_book(DUNE)
    with pytest.raises(DuplicateIsbn):
        service.add_book(DUNE)


def test_review_for_missing_book(uow: UnitOfWork) -> None:
    with pytest.raises(BookNotFound):
        BookService(uow).add_review(99, ReviewCreate(rating=5))


def test_reviews_are_listed_per_book(uow: UnitOfWork) -> None:
    service = BookService(uow)
    book = service.add_book(DUNE)
    service.add_review(book.id, ReviewCreate(rating=5, text="Spice"))
    assert [r.text for r in service.list_reviews(book.id)] == ["Spice"]
tests/test_api.py
from unittest.mock import create_autospec

from fastapi.testclient import TestClient

from app.dependencies import get_book_service
from app.main import app
from app.services.books import BookService
from app.services.errors import BookNotFound

from .fakes import FakeUnitOfWork

BOOK = {"title": "Dune", "author": "Frank Herbert", "isbn": "9780441013593"}


def test_create_and_read_with_a_fake(client: TestClient, fake_uow: FakeUnitOfWork) -> None:
    response = client.post("/api/v1/books/", json=BOOK)
    assert response.status_code == 201
    assert fake_uow.committed
    assert client.get("/api/v1/books/1").json()["title"] == "Dune"


def test_duplicate_isbn_is_409(client: TestClient) -> None:
    client.post("/api/v1/books/", json=BOOK)
    assert client.post("/api/v1/books/", json=BOOK).status_code == 409


def test_nested_review_route(client: TestClient) -> None:
    client.post("/api/v1/books/", json=BOOK)
    created = client.post("/api/v1/books/1/reviews/", json={"rating": 5})
    assert created.status_code == 201
    assert client.get("/api/v1/books/1/reviews/").json()[0]["book_id"] == 1


def test_web_layer_alone_with_a_mock(client: TestClient) -> None:
    """A mock checks the interaction: the router must call the service."""
    service = create_autospec(BookService, instance=True)
    service.get_book.side_effect = BookNotFound(7)
    app.dependency_overrides[get_book_service] = lambda: service
    response = client.get("/api/v1/books/7")
    assert response.status_code == 404
    service.get_book.assert_called_once_with(7)

The first three API tests use the fake through dependency_overrides; the last one replaces the service with an autospec mock (it fails if the router calls a method that BookService does not have, which a plain MagicMock would accept) and checks the interaction. The test run with pytest -v on Python 3.13 gave 12 passed in 0.07 s:

tests/test_api.py::test_create_and_read_with_a_fake PASSED
tests/test_api.py::test_duplicate_isbn_is_409 PASSED
tests/test_api.py::test_nested_review_route PASSED
tests/test_api.py::test_web_layer_alone_with_a_mock PASSED
tests/test_service.py::test_add_and_get[fake] PASSED
tests/test_service.py::test_add_and_get[sqlite] PASSED
tests/test_service.py::test_duplicate_isbn_rejected[fake] PASSED
tests/test_service.py::test_duplicate_isbn_rejected[sqlite] PASSED
tests/test_service.py::test_review_for_missing_book[fake] PASSED
tests/test_service.py::test_review_for_missing_book[sqlite] PASSED
tests/test_service.py::test_reviews_are_listed_per_book[fake] PASSED
tests/test_service.py::test_reviews_are_listed_per_book[sqlite] PASSED

Prefer fakes for most tests and keep mocks for the edges. The full testing toolbox, including async tests and the TestClient deprecation warning that Starlette 1.7 prints with HTTPX, is in the Testing page.

The Full Stack FastAPI Template as a Reference Layout

The FastAPI project maintains a template repository, the Full Stack FastAPI Template, which the documentation lists as a starting point: FastAPI with SQLModel and PostgreSQL, a React front end, Docker Compose, JWT authentication, tests with Pytest and CI. Its backend directory is a good second opinion on layout. As of 2026-10-02 it contains, abridged:

backend/
├── pyproject.toml            # has a [tool.fastapi] table
├── alembic.ini
├── app/
│   ├── main.py               # FastAPI(...), CORS, include_router(api_router, prefix=...)
│   ├── models.py             # SQLModel tables and API schemas in one module
│   ├── crud.py               # database functions
│   ├── utils.py
│   ├── api/
│   │   ├── main.py           # api_router that includes each router
│   │   ├── deps.py           # SessionDep, TokenDep, CurrentUser (Annotated aliases)
│   │   └── routes/           # items.py, login.py, users.py, utils.py, private.py
│   ├── core/                 # config.py, db.py, security.py
│   └── alembic/              # migrations
└── tests/                    # api/, crud/, utils/, conftest.py

What to take from it, and what differs from the layout on this page:

  • The same bones. An api package with one router module per resource, an aggregating api_router included once with a version prefix, a deps.py with Annotated dependency aliases, a core package for settings, database and security helpers, and tests mirroring the application layout.

  • Flatter data access. The template keeps SQLModel tables and API schemas in one models.py and uses plain functions in crud.py, which is simpler and entirely reasonable for CRUD-heavy services. The layout on this page adds a service layer and ports; adopt that when the use cases contain real rules or when you need to test them without a database, not by default.

  • A moving target. The template is a living repository, its structure and versions change (it now serves the built front end through app.frontend()), and the listing above is a snapshot. Treat it as a reference to read, and copy deliberately rather than treating it as a standard.

iter_route_contexts() and the Route Tree

Until FastAPI 0.136, include_router copied each path operation of the included router into the parent, so app.router.routes was a flat list of APIRoute objects with their final paths. Tools that needed every route — a documentation generator, a permissions audit, the Jupyverse project the release notes mention — simply looped over it.

FastAPI 0.137.0 (2026-06-14) changed this. It is listed as a breaking change in the release notes: APIRouter and APIRoute instances are now preserved, and inclusion stores "router X includes Y" as an intermediate object. Consequently router.routes is no longer a flat list of final routes but a tree that can contain these intermediate objects, and it should be considered an internal implementation detail. The benefits described in the same release are that routes added after inclusion are reflected and that sub-routers can be included before routes are added to them (the behaviour seen in the dependency-order example above). The tutorial says the same: do not mutate router.routes after inclusion, use the path operation decorators and include_router, and do not rely on routes as a flat list.

FastAPI 0.137.2 (2026-06-18) added iter_route_contexts() "for advanced use cases that used to use router.routes`". It lives in `fastapi.routing, takes a sequence of routes and yields a RouteContext for each effective route, flattening the tree. It is not described in the tutorial or the reference pages as of 0.142.2, only in the release notes, and the objects it yields are not part of the documented API, so treat it as an advanced, version-sensitive tool and pin FastAPI if you use it.

On the Bookshelf application, iterating over app.router.routes shows the tree and iter_route_contexts() the flat routes:

from fastapi.routing import iter_route_contexts

from app.main import app

for route in app.router.routes:
    print(type(route).__name__, getattr(route, "path", None))

for context in iter_route_contexts(app.router.routes):
    print(sorted(context.methods or []), context.path, context.name)
Route /openapi.json
Route /docs
Route /docs/oauth2-redirect
Route /redoc
_IncludedRouter None
_IncludedRouter None

['GET', 'HEAD'] /openapi.json openapi
['GET', 'HEAD'] /docs swagger_ui_html
['GET', 'HEAD'] /docs/oauth2-redirect swagger_ui_redirect
['GET', 'HEAD'] /redoc redoc_html
['GET'] /api/v1/books/{book_id}/reviews/ list_reviews
['POST'] /api/v1/books/{book_id}/reviews/ create_review
['GET'] /api/v1/books/ list_books
['POST'] /api/v1/books/ create_book
['GET'] /api/v1/books/{book_id} read_book
['GET'] /admin/stats stats

Observations from this run on 0.142.2:

  • app.router.routes holds the four built-in documentation routes plus two private _IncludedRouter objects, one per include_router call — not the six application routes.

  • iter_route_contexts() yields a RouteContext per route, including nested ones, with the effective path (prefixes of every inclusion level applied), name, methods and endpoint; original_route gives the underlying route object, whose own path is only what the router declared (for list_reviews that is /{book_id}/reviews/).

  • Contexts come in inclusion order, which is why the reviews routes (included into the books router first) precede the books routes.

  • RouteContext and the underscore-prefixed classes live in fastapi.routing; the underscore ones are private. Depend on iter_route_contexts() and the attributes above, and not on _IncludedRouter.

For everyday needs, you rarely need any of this: the OpenAPI document (app.openapi()["paths"]) is the supported way to list the public routes of an application, as the earlier example did.

The Final Bookshelf Layout

Putting the pieces together, this is the layout the rest of the section assumes. Later pages add to it (the database models of the SQL page, the security module, the WebSocket and SSE routers) without changing the idea:

Path Contents

pyproject.toml

Dependencies pinned to a FastAPI minor version, and [tool.fastapi] entrypoint = "app.main:app".

app/main.py

The FastAPI instance, include_router calls, exception handlers, and later the lifespan and middleware. Nothing else.

app/routers/

Web layer: one APIRouter module per resource (books, reviews, admin, later auth, authors).

app/models/

Pydantic (and later SQLModel) schemas shared by the layers.

app/services/

Service layer: use cases, domain errors and ports.

app/db.py

Data layer: adapters implementing the ports; a package (app/data/) once there are several.

app/dependencies.py

Depends() providers and the reusable Annotated aliases (SettingsDep, UnitOfWorkDep, BookServiceDep).

app/settings.py

The BaseSettings class and its cached provider.

tests/

conftest.py with the override fixtures, fakes.py with test doubles, and tests for service and API.

Growth rules that keep this honest:

  • A router module that goes past a few hundred lines becomes a package with one module per sub-resource.

  • dependencies.py is the only place that imports both the web layer’s needs and the data layer’s adapters. Routers import aliases from it; services import neither routers nor adapters.

  • Keep main.py boring. Anything that reads like logic belongs in a service, anything that reads like configuration in settings.

  • Do not create layers you have no use for. A service that only forwards calls to a repository is ceremony; the template above works well without one.

Effect on OpenAPI and the Interactive Docs

Structure is invisible in the schema except through the metadata routers contribute:

  • prefix and the prefixes given to include_router become part of every path, at every nesting level.

  • tags of the router, of the inclusion and of the operation are concatenated, and Swagger UI groups operations by them. Descriptions of tags come from the openapi_tags parameter of FastAPI, see OpenAPI & Interactive Docs.

  • responses from the router, the inclusion and the operation are merged; 422 is added automatically.

  • dependencies that declare parameters (like X-Admin-Token in require_admin) add those parameters to every operation of the router. That is why GET /admin/stats lists x-admin-token and a 422, although the function itself has no parameters.

  • operationId values default to a name built from the function and the path. If your front end generates a client from the schema, set generate_unique_id_function on the application or the router so that the generated method names are stable.

Pitfalls

  • Prefix with a trailing slash. APIRouter(prefix="/books/") raises an assertion error; use /books.

  • Both prefix and path empty. A route with "" on a router with no prefix cannot be included.

  • Import cycles. main.py imports routers, routers import dependencies, dependencies import services and adapters. If a module in that chain imports main (to get app), Python raises a circular import. Pass what a module needs as parameters or dependencies, never import the application object from below.

  • Importing the variable, not the module. Two from …​ import router lines override each other (see Package Layout).

  • Forgetting include_router. A router that is never included produces no routes and no error; the paths simply return 404.

  • Dependency results ignored. dependencies=[Depends(…​)] on a router or an inclusion discards the return value. If an operation needs the value, declare the parameter in the function too; the cache makes this free within a request.

  • Putting HTTP in the service. Raising HTTPException from the service layer couples it to FastAPI; raise domain errors and map them in the web layer.

  • Fakes that drift. A fake used by many tests that is never compared with the real adapter becomes fiction. Run the same contract tests against both, as the uow fixture does.

  • Overrides left behind. Forgetting app.dependency_overrides.clear() makes tests depend on order; put it in a fixture teardown.

  • Sharing a SQLite connection between threads. Open it inside the use case, as the adapter above does, or use a connection pool.

  • Over-layering. Three layers for a ten-endpoint CRUD service is more code to read and to keep in sync. Start flat as the template does, and introduce the service layer when business rules appear.

  • Relying on router.routes. Since 0.137.0 it is a tree and an internal detail; use the OpenAPI schema, or iter_route_contexts() if you must.

Where Older Guides Are Outdated, and What Changed Recently

  • Fake versus real data layer. A common older approach selects a fake or real module with an environment variable at import time. Use app.dependency_overrides and dependency providers instead; the production code stays free of test switches. Configuration (a path, a URL) comes from pydantic-settings.

  • Module-global connections. A single module-level database connection shared by every request is thread-unsafe and hard to test. Open connections or sessions per request or per use case, with a dependency or a Unit of Work.

  • Running the application. Older guides start uvicorn app.main:app --reload. The fastapi command (0.111.0, 2024-05-03) with [tool.fastapi] entrypoint replaces the repeated import string.

  • Inclusion copied routes. Before 0.137.0 (2026-06-14), include_router cloned each route into the parent and routes added to a router after inclusion were lost. Now the router object is preserved and later additions are visible. router.routes became a tree, and iter_route_contexts() arrived in 0.137.2 (2026-06-18) for tools that need a flat list.

  • Annotated dependency aliases. Older tutorials repeat Depends(…​) in every signature; reusable aliases such as BookServiceDep are the current style (Annotated support since 0.95.0, 2023-03-18).

  • Startup and shutdown. Older layouts create clients in @app.on_event("startup"). The current mechanism is a lifespan function (0.93.0, 2023-03-07), covered in Settings, Lifespan & Background Tasks.

  • Testing by calling functions. Older guides call path functions directly and bypass routing. Use TestClient with dependency overrides so the router, validation and serialization are exercised too.