Минимальное FastAPI-приложение умещается в пять строк, и это обманчиво: те же пять строк через полгода превращаются в один файл на тысячу строк, где роуты, конфигурация и подключение к базе перемешаны. Структура и конфигурация — это решения, которые стоит принять в первый день, потому что переделывать их потом дорого.

Здесь три отдельных вопроса: как разложить приложение на роутеры, как управлять ресурсами на старте и остановке, и как читать настройки. FastAPI даёт идиоматичный ответ на каждый.

Приложение и роутеры

Объект FastAPI — корень приложения. Класть все эндпоинты прямо на него не стоит: уже на втором домене файл становится нечитаемым. Идиома — APIRouter на каждый домен, со своим префиксом и тегом, а корневое приложение их подключает.

from fastapi import APIRouter

router = APIRouter(prefix="/products", tags=["products"])


@router.get("/{product_id}")
async def get_product(product_id: int):
    ...
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-клиент к соседнему сервису, потребитель очереди. Для этого во FastAPI есть 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.on_event("startup") / @app.on_event("shutdown") — устарел; 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 или какие переменные окружения поданы: один и тот же класс читает разные значения. Получать настройки в эндпоинтах стоит через Annotated[Settings, Depends(get_settings)] — тогда в тестах их легко подменить.

Раскладка UCP-сервиса

FastAPI не навязывает структуру каталогов, поэтому её задаёт методология. В UCP-сервисе слои те же, что в любом биндинге: тонкий контроллер принимает запрос и делегирует, Handler несёт сценарий, репозиторий ходит в базу. На FastAPI это ложится так: роутер — это контроллер, он не содержит бизнес-логики, а вызывает Handler, полученный через зависимость.

@router.post("/", status_code=201)
async def create_product(
    body: CreateProductRequest,
    handler: Annotated[CreateProductHandler, Depends(get_create_product_handler)],
) -> ProductResponse:
    product = await handler.handle(body.to_command())
    return ProductResponse.from_domain(product)

Раскладка по пакетам-доменам (app/products/, app/orders/), внутри каждого — routes.py, handlers.py, repository.py, models.py — держит домен собранным в одном месте и не даёт ему растечься. Это та же дисциплина границ, что и в Spring-биндинге, только средствами Python.

Когда приложение разложено на роутеры, ресурсы живут в lifespan, а настройки типизированы, всё остальное — внедрение зависимостей, роутинг, данные — встаёт на готовый каркас. Это и есть фундамент, на котором продукт-инженер собирает Python-сервис.