Минимальное FastAPI-приложение умещается в пять строк — и это обманчиво. Те же пять строк через полгода превращаются в один файл на тысячу строк, где роуты, конфигурация и подключение к базе перемешаны. Структура и конфигурация — это решения, которые стоит принять в первый день, потому что переделывать их потом долго и неприятно.
Здесь три отдельных вопроса: как разложить приложение на роутеры, как управлять ресурсами на старте и остановке, и как читать настройки. FastAPI даёт идиоматичный ответ на каждый.
Объект FastAPI: что это такое
Всё начинается с одной строки:
from fastapi import FastAPI
app = FastAPI(title="Catalog Service")
app — это корневой объект приложения. Он знает обо всех маршрутах, подключённых роутерах, жизненном цикле. Uvicorn (или любой другой ASGI-сервер) запускает именно его.
Класть все эндпоинты прямо на app не стоит — уже на втором-третьем домене (продукты, заказы, пользователи) файл станет нечитаемым. Для разделения есть APIRouter.
Роутеры: одна ответственность на файл
APIRouter — это кусок маршрутов, объединённых общей темой. Каждый домен получает свой роутер со своим префиксом, а в конце они подключаются к корневому приложению.
# app/products/routes.py
from fastapi import APIRouter
router = APIRouter(prefix="/products", tags=["products"])
@router.get("/{product_id}")
async def get_product(product_id: int):
...
# main.py
from fastapi import FastAPI
from app.products.routes import router as products_router
from app.orders.routes import router as orders_router
app = FastAPI(title="Catalog Service")
app.include_router(products_router)
app.include_router(orders_router)
Каждый роутер живёт в своём пакете рядом со своей логикой — домены не растекаются друг в друга, и новый разработчик сразу понимает, где искать код по продуктам.
Жизненный цикл приложения: lifespan
Любому сервису нужны ресурсы, которые создаются один раз на старте и аккуратно закрываются при остановке: пул соединений к базе данных, HTTP-клиент к соседнему сервису, подключение к очереди сообщений.
Раньше для этого использовали два отдельных обработчика:
@app.on_event("startup")
async def on_startup():
app.state.engine = create_async_engine(DATABASE_URL)
@app.on_event("shutdown")
async def on_shutdown():
await app.state.engine.dispose()
Проблема такого подхода: создание и закрытие ресурса разнесены по двум функциям — легко забыть закрыть то, что открыл.
Современный способ — lifespan. Это асинхронный контекст-менеджер: код до yield выполняется на старте, код после — на остановке. Создание и закрытие всегда рядом.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import get_settings
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
app.state.engine = create_async_engine(settings.database_url)
yield
await app.state.engine.dispose()
app = FastAPI(lifespan=lifespan)
Ресурсы кладут на app.state или в замыкание. Доставать их в эндпоинты лучше через зависимость, а не через глобальную переменную — подробнее об этом в статье про Dependency Injection.
Конфигурация: pydantic-settings
Плохая практика — разбросать os.getenv(...) по всему коду. Найти, где что читается, становится трудно, а если переменная не задана — приложение падёт в неожиданном месте.
pydantic-settings решает это: вы описываете все настройки как одну типизированную модель. Библиотека читает значения из переменных окружения и .env-файла, проверяет типы и валидирует на старте. Если обязательной переменной нет — приложение падает сразу при запуске с понятной ошибкой, а не посередине обработки запроса.
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
database_url: str
debug: bool = False
jwt_secret: str
@lru_cache
def get_settings() -> Settings:
return Settings()
@lru_cache делает настройки одиночкой: модель строится один раз при первом вызове, потом возвращается кэшированный объект.
Профили окружения (локальный, тестовый, продовый) задаются не кодом, а тем, какие переменные окружения или какой .env-файл подан снаружи. Один и тот же класс Settings читает разные значения. Получать настройки в эндпоинтах стоит через зависимость:
from typing import Annotated
from fastapi import Depends
@router.get("/info")
async def info(settings: Annotated[Settings, Depends(get_settings)]):
return {"debug": settings.debug}
Так в тестах настройки легко подменить через override_dependency.
Как разложить код по файлам
FastAPI не навязывает структуру каталогов. Хорошо работает раскладка по доменам: каждый домен — отдельный пакет, внутри которого лежит всё, что к нему относится.
app/
products/
routes.py # роутер и эндпоинты
handlers.py # бизнес-логика
repository.py # работа с базой
models.py # схемы запросов и ответов
orders/
routes.py
handlers.py
repository.py
models.py
config.py # Settings и get_settings
main.py # создание app и подключение роутеров
Такая раскладка держит домен собранным в одном месте. Когда нужно разобраться в заказах — идёшь в app/orders/, и всё нужное там.
Коротко
FastAPI()— корневой объект приложения; все маршруты подключаются к нему.APIRouter— группа маршрутов одного домена; каждый домен получает свой роутер со своим префиксом.lifespanзаменил@app.on_event— держит создание и закрытие ресурсов в одной функции, ничего не теряется.pydantic-settingsсобирает все настройки в одну типизированную модель; если переменная не задана — приложение падает сразу на старте, а не в середине работы.- Профили (локальный, тест, прод) — это не код, а набор переменных окружения; класс
Settingsодин. - Раскладка по доменным пакетам держит код организованным и не даёт доменам растекаться.
Что почитать дальше
- Dependency Injection во FastAPI — как доставать ресурсы и настройки в эндпоинтах через зависимости.
- Роутинг и запросы — параметры пути, query, заголовки, тело запроса.