05 июн. 2025 г.·6 мин чтения

Паттерны внедрения зависимостей в FastAPI, которые остаются понятными

Паттерны внедрения зависимостей в FastAPI помогают держать конфиг, авторизацию и доступ к базе понятными. Узнайте простую структуру, которая остаётся читаемой через месяцы.

Паттерны внедрения зависимостей в FastAPI, которые остаются понятными

Почему понятные зависимости быстро превращаются в кашу

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

@router.post("/subscriptions")
async def create_subscription(payload: SubscriptionIn):
    return await service.create(payload)

Потом это превращается вот во что:

@router.post("/subscriptions")
async def create_subscription(
    payload: SubscriptionIn,
    settings: Settings = Depends(get_settings),
    user: User = Depends(require_user),
    db: Session = Depends(get_db),
    plan: Plan = Depends(load_plan),
):
    return await service.create(payload, settings, user, db, plan)

Проблема не только в количестве аргументов. Маршрут становится трудным для чтения, когда разные виды входных данных выглядят одинаково. Данные запроса стоят рядом с сервисами приложения и контекстом запроса, хотя отвечают на разные вопросы.

payload пришёл от клиента. settings и db пришли из приложения. user пришёл из запроса после проверки авторизации. Когда всё это сваливается в одну сигнатуру функции, новому коллеге приходится останавливаться и разбирать, что здесь настоящее бизнес-входное значение, а что просто техническая обвязка.

Скрытые побочные эффекты только ухудшают ситуацию. Зависимость с названием require_user может ещё и проверять статус оплаты, писать audit-лог и отклонять заблокированные аккаунты. Зависимость get_db может открывать транзакцию или коммитить изменения на выходе. Снаружи маршрут выглядит коротким, но настоящее поведение спрятано в нескольких местах.

Хорошие паттерны внедрения зависимостей в FastAPI делают маршрут понятным сверху вниз. Читатель должен быстро увидеть три вещи:

  • что прислал клиент
  • что внедрило приложение
  • какие проверки происходят до выполнения действия

Это не значит, что каждый маршрут обязан быть крошечным. Это значит, что каждая зависимость должна делать одну очевидную работу, а маршрут всё равно должен читаться как маленькая история. Если запрос на подписку падает, команда должна понимать почему, не открывая сначала пять файлов.

Разделите зависимости на три группы

Когда проект FastAPI растёт, проблема редко заключается в количестве зависимостей. Обычно проблема в том, что каждая зависимость начинает делать свою собственную работу. Одна проверяет токен, другая читает настройки и открывает клиент, а третья незаметно решает, может ли пользователь перейти на новый тариф. Через несколько месяцев маршрут всё ещё работает, но читать его уже приходится как загадку.

Простой раздел помогает сохранить читаемость:

  • Настройки: конфиг приложения, feature flags, значения окружения, общие параметры
  • Контекст запроса: проверки auth, текущий пользователь, tenant, данные идентичности на уровне запроса
  • Ресурсы: сессии базы данных, cache-клиенты, message broker'ы, клиенты сторонних API

Такое разделение даёт каждой зависимости одну ясную задачу. Настройки отвечают на вопрос: «Как настроено приложение?» Контекст запроса отвечает: «Кто обращается?» Ресурсы отвечают: «К каким системам может обратиться этот маршрут?» Если зависимость не попадает ни в одну из этих групп, ей чаще всего место в сервисе или обычной функции.

Держите бизнес-правила подальше от функций-зависимостей. Зависимость может получить текущего пользователя, но не должна решать, может ли он отменить подписку, оформить возврат или получить доступ к beta-функции. Такую логику лучше держать в сервисе, который маршрут вызывает осознанно. Маршрут остаётся простым для просмотра, а правило — простым для тестирования.

Именно здесь многие паттерны внедрения зависимостей в FastAPI становятся трудными для понимания. Люди начинают использовать зависимости как место, куда можно спрятать любое решение. Сегодня это экономит пару строк, а завтра отнимает час на исправление бага, потому что никто не понимает, что именно выполняется до тела обработчика.

Небольшой пример хорошо показывает границу. Если endpoint для подписки нуждается в лимите тарифа, вошедшем пользователе и сессии БД, это три отдельных входа. Слой зависимостей должен передать эти значения маршруту и остановиться. А маршрут или сервис, который он вызывает, уже должны решить, может ли пользователь менять тариф.

Такое разделение выглядит почти скучно, и это хороший знак. Скучный код живёт дольше.

Передавайте настройки одним и тем же способом

Настройки начинают путаться, когда каждый модуль читает свои переменные окружения. Один маршрут вызывает os.getenv, другой импортирует глобальную переменную, а третий задаёт значения по умолчанию прямо внутри функции. Через полгода никто не понимает, какое значение приложение использует на самом деле.

Более чистый подход очень простой: создайте один объект настроек при запуске приложения, а затем передавайте его через одну небольшую зависимость. Так у всех маршрутов будет один источник истины, а тесты станут предсказуемыми.

from fastapi import FastAPI, Request, Depends
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "My API"
    jwt_public_key: str
    database_url: str
    billing_api_base_url: str


def create_app() -> FastAPI:
    app = FastAPI()
    app.state.settings = Settings()
    return app


app = create_app()


def get_settings(request: Request) -> Settings:
    return request.app.state.settings

Это хорошо работает, потому что окружение читается только в одном месте: в Settings(). Маршрутам не важно, откуда пришли значения. Им нужно только получить Settings и использовать то, что требуется.

Понятные имена важнее, чем кажется. database_url — нормально. db — слишком расплывчато. billing_api_base_url сразу говорит, куда указывает значение. Если endpoint отправляет письмо, email_sender_address вызывает больше доверия, чем просто sender.

Держите зависимость маленькой. get_settings() должна только вернуть объект и ничего больше. Не прячьте в ней логику, не подменяйте значения и не делайте сетевые запросы. Если настройки нужно валидировать, делайте это при старте приложения, чтобы ошибки всплывали сразу.

Маршрут остаётся понятным, когда параметр настроек соответствует его задаче:

@app.get("/subscriptions/{user_id}")
def get_subscription(user_id: str, settings: Settings = Depends(get_settings)):
    return {
        "user_id": user_id,
        "billing_base_url": settings.billing_api_base_url,
    }

Такой стиль хорошо масштабируется. Вы можете одинаково передавать конфиг, auth и доступ к базе, но у каждого из них по-прежнему будет один понятный путь в маршрут.

Держите проверки авторизации рядом с маршрутом

Auth становится трудным для чтения, когда один маршрут подтягивает разбор токена, поиск пользователя, проверку роли и tenant-проверки отдельными зависимостями. Через полгода никто уже не помнит, какая зависимость блокирует какое действие.

Лучше, если auth вернёт один объект контекста пользователя, а правило доступа будет находиться рядом с кодом, которому оно нужно. Маршрут остаётся коротким, а правило — на виду.

from dataclasses import dataclass
from fastapi import Depends, HTTPException

@dataclass
class UserContext:
    user_id: str
    account_id: str
    roles: set[str]

async def get_current_user(...) -> UserContext:
    # parse token, load user, build context
    return UserContext(user_id="u_123", account_id="a_456", roles={"billing_admin"})

Такая зависимость должна отвечать на один вопрос: «Кто обращается к этому маршруту?» Она не должна принимать все бизнес-решения сразу. Как только вы смешиваете идентичность и логику прав, простые маршруты начинают напоминать головоломку.

@router.post("/accounts/{account_id}/subscription")
async def update_subscription(
    account_id: str,
    payload: SubscriptionUpdate,
    user: UserContext = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    ensure_can_manage_subscription(user, account_id)
    return subscription_service.update(db, account_id, payload)

Это читать проще, чем складывать auth-помощники в сигнатуру. Если написать маршрут вот так, читателю придётся слишком далеко уходить в код, прежде чем он всё поймёт:

  • user = Depends(get_current_user)
  • account = Depends(get_account_member)
  • permission = Depends(require_billing_admin)

Маршрут уже содержит всё, что ему нужно знать. Небольшая проверка рядом с вызовом сервиса обычно понятнее.

Называйте auth-хелперы по тому правилу, которое они проверяют. ensure_can_manage_subscription() понятно. require_admin() — уже нет, потому что слово «admin» в разных частях приложения часто означает разное. Хорошие имена экономят время на code review и при исправлении ошибок.

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

Обрабатывайте сессии базы данных, не пряча работу

Получите Fractional CTO support
Привлеките опытного CTO для архитектуры backend и поддержки команды

Сессия БД должна ощущаться скучной. Если людям приходится гадать, кто её открыл, кто делает commit и где происходит rollback, маршруту становится трудно доверять.

Используйте одну сессию на запрос и передавайте её дальше. Не позволяйте репозиторию создавать свою собственную сессию за кадром. Сначала это кажется аккуратным, а потом никто уже не понимает, какой запрос живёт в какой транзакции.

from collections.abc import Generator
from fastapi import Depends
from sqlalchemy.orm import Session


def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


def get_user_repo(db: Session = Depends(get_db)) -> UserRepo:
    return UserRepo(db)

Такой подход делает владение ресурсом понятным. FastAPI открывает запрос, ваша зависимость создаёт одну сессию, а все репозитории и сервисы используют один и тот же объект.

Передавать репозиторий или сервис имеет смысл, когда это убирает шум, а не когда это скрывает работу. Репозиторий подходит для чтения, например для get_user_by_id() или list_active_subscriptions(). Сервис подходит, когда одно действие затрагивает несколько таблиц. Но ни тот ни другой не должны тихо делать commit. Скрытые коммиты — это источник странных багов.

Выберите один слой для записи и придерживайтесь его. Многие команды размещают commit() и rollback() в сервисном слое. Это работает хорошо, потому что сервис уже знает всю операцию целиком. Он может создать запись, обновить связанные строки и либо сохранить всё, либо ничего.

Маршруты для чтения и записи снаружи всё равно должны выглядеть похоже. Маршрут внедряет зависимость, вызывает один метод и возвращает результат. Разница простая: методы чтения получают данные, а методы записи завершают транзакцию в одном понятном месте.

Чистый маршрут остаётся коротким:

@router.post("/users")
def create_user(payload: CreateUserIn, service: UserService = Depends(get_user_service)):
    user = service.create_user(payload)
    return UserOut.model_validate(user)

Если в одном маршруте используется Session, в другом UserRepo, а в третьем BillingService, который ещё и открывает свою собственную сессию, кодовая база начинает казаться хаотичной. Одна сессия на запрос, один понятный слой записи и один видимый паттерн для чтения и записи помогают легко понимать работу с БД даже через месяцы.

Собирайте маршрут по шагам

Маршрут остаётся понятным, когда вы добавляете по одной зависимости за раз. Хорошие паттерны внедрения зависимостей в FastAPI не начинают с Depends(...) повсюду. Они начинают с тела запроса, а потом добавляют только те внешние данные, которые хендлеру действительно нужны.

@router.post("/subscriptions")
async def create_subscription(payload: SubscriptionCreate):
    return {"plan": payload.plan}

Эта первая версия выглядит почти слишком простой, и именно в этом смысл. Сразу видно бизнес-действие: клиент отправляет данные подписки, а маршрут их обрабатывает.

Следом добавьте настройки. Конфиг обычно стабилен и понятен, поэтому это хорошая первая зависимость. Если каждый маршрут получает конфиг через одну и ту же функцию get_settings, никому не нужно искать переменные окружения или скрытые глобальные значения.

@router.post("/subscriptions")
async def create_subscription(
    payload: SubscriptionCreate,
    settings: Settings = Depends(get_settings),
):
    return {"plan": payload.plan, "trial_days": settings.default_trial_days}

Теперь добавьте пользователя. Авторизация меняет то, что маршруту разрешено делать, поэтому держите её близко к маршруту, а не прячьте глубоко в вспомогательном коде. Читателю не должно требоваться открывать три файла, чтобы понять, кто может вызывать этот endpoint.

Добавьте сессию БД последней. Это техническая обвязка, а не основная идея. К этому моменту у маршрута уже есть четыре понятных входа: данные запроса, настройки, пользователь и БД.

class SubscriptionService:
    def __init__(self, settings: Settings, db: Session):
        self.settings = settings
        self.db = db

    def create_for_user(self, user: User, payload: SubscriptionCreate):
        trial_days = self.settings.default_trial_days
        subscription = Subscription(
            user_id=user.id,
            plan=payload.plan,
            trial_days=trial_days,
        )
        self.db.add(subscription)
        self.db.commit()
        self.db.refresh(subscription)
        return subscription


@router.post("/subscriptions")
async def create_subscription(
    payload: SubscriptionCreate,
    settings: Settings = Depends(get_settings),
    user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    service = SubscriptionService(settings=settings, db=db)
    return service.create_for_user(user=user, payload=payload)

На этом месте лучше остановиться. Конструктор маленький, маршрут всё ещё помещается на один экран, а сервис хранит повторяющуюся логику. Если сигнатура начинает разрастаться дальше, уменьшите её до того, как добавите ещё больше. Через полгода четыре входа всё ещё кажутся понятными. Восемь обычно означают, что маршрут делает слишком много.

Пример endpoint'а подписки, которому нужны все три слоя

Проверьте свои маршруты FastAPI
Получите точный разбор архитектуры, зависимостей, сессий и потока авторизации

Изменение тарифа — хорошая проверка на читаемость паттернов внедрения зависимостей в FastAPI. Один запрос нуждается в данных тела, настройках приложения, проверке прав и записи в БД. Если спрятать слишком много, маршрут превращается в угадайку.

Держите каждую часть очевидной. Тело запроса несёт новый plan ID. Настройки содержат допустимые тарифы и цены. Auth решает, может ли этот пользователь трогать billing. Один сервис записывает изменение.

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

router = APIRouter()

class ChangePlanBody(BaseModel):
    plan_id: str

@router.post("/subscription/change")
def change_subscription(
    body: ChangePlanBody,
    user: CurrentUser = Depends(require_billing_access),
    settings: Settings = Depends(get_settings),
    billing: BillingService = Depends(get_billing_service),
):
    plan = settings.pricing.plans.get(body.plan_id)
    if plan is None:
        raise HTTPException(status_code=404, detail="Unknown plan")

    updated = billing.change_plan(
        account_id=user.account_id,
        plan_id=body.plan_id,
        price_cents=plan.price_cents,
        changed_by=user.id,
    )

    return {
        "subscription_id": updated.id,
        "plan_id": updated.plan_id,
        "price_cents": updated.price_cents,
    }

Этот маршрут остаётся понятным, потому что у каждой зависимости одна задача. require_billing_access отвечает на простой вопрос: может ли этот пользователь менять billing? get_settings даёт маршруту правила ценообразования, которые нужны прямо сейчас. get_billing_service даёт одну точку, куда отправляется запись.

Типичная ошибка здесь тоже встречается часто. Команды загружают цены внутри сервиса, проверяют права в middleware и всё равно передают в маршрут сырой session database. В итоге endpoint работает, но никто не может понять, где именно принимается решение.

Лучшее разделение простое:

  • Маршрут читает запрос и выбирает тариф из настроек.
  • Auth-зависимость отклоняет пользователей без прав на billing.
  • Сервис делает запись в БД и хранит логику изменения в одном месте.

Представьте реальный случай. Через полгода команда добавляет новый тариф «pro». При таком подходе они обновляют цены в настройках, оставляют правило доступа там, где оно объявлено в маршруте, и не трогают путь записи. Такой код можно просканировать за 20 секунд и ему можно доверять.

Ошибки, из-за которых зависимости превращаются в головоломку

Большинство маршрутов становится трудным для чтения не потому, что сама функция слишком длинная, а потому что сложная часть прячется чуть дальше от маршрута. Аккуратная сигнатура всё равно может скрывать неожиданное поведение, и именно это позже создаёт проблемы.

Одна распространённая ошибка — прятать запись в БД внутри auth-хелперов. Функция с названием get_current_user() звучит безобидно, но некоторые команды ещё и обновляют last_seen, пишут audit-строку или обновляют токен внутри неё. Теперь маршрут, который выглядит только как чтение, изменяет данные при каждом вызове. Когда появляется баг, никто не начинает искать его в auth-зависимости.

Ещё одна проблема — возвращать сырые словари из каждой зависимости. Словарь удобен в начале, но быстро становится слишком расплывчатым. Один маршрут ждёт user["id"], другой — user["account_id"], а третий предполагает, что user["role"] всегда существует. Небольшие типизированные объекты читать проще, а использовать их неправильно — сложнее. Заодно улучшаются подсказки в редакторе и тесты.

Где люди обычно заходят слишком далеко

Ещё одна большая ловушка — помещать Depends внутрь утилитных функций и методов сервисов. Так обычный Python-код оказывается привязан к самому FastAPI. Как только вы захотите вызвать тот же сервис из фоновой задачи, скрипта или теста, код станет неудобным.

Сервисные функции должны принимать реальные аргументы, например db, settings или current_user. Пусть зависимости разрешает маршрут, а потом передаёт их дальше.

Команды также создают один большой объект, который смешивает конфиг, состояние пользователя и бизнес-правила. Обычно он начинается как что-то вроде RequestContext, а потом разрастается до объекта, в котором лежат настройки, сессия базы данных, текущий пользователь, feature flags и половина приложения. Это экономит пару параметров, но скрывает владение. Любая функция может залезть в этот объект и тронуть что угодно.

Несколько красных флагов заметны очень рано:

  • auth-зависимости пишут в базу данных
  • зависимости возвращают неструктурированные dict вместо типизированных данных
  • сервисы импортируют и используют Depends
  • один context-объект тащит в себе несвязанные данные

Endpoint подписки помогает это увидеть особенно хорошо. Если маршрут проверяет авторизацию, читает настройки и записывает изменение тарифа, эти три действия должны оставаться на виду. Auth-зависимость должна идентифицировать пользователя. Зависимость настроек должна возвращать настройки. Сессия БД должна выполнять запись там, где маршрут или сервис явно её вызывают.

Короткий чек-лист перед merge

Уточните границы сервисов
Уберите бизнес-правила из зависимостей и оставьте хендлеры простыми для чтения

Маршрут может выглядеть аккуратно в первый день и всё равно начать путать людей через шесть недель. Перед merge прочитайте сигнатуру так, как будто вы коллега, который её не писал. Если человеку нужно прыгать по пяти файлам, чтобы понять значение каждого аргумента, маршрут уже слишком умный.

Используйте четыре короткие проверки.

  • У каждого параметра должна быть одна задача в одном предложении. settings даёт конфиг приложения. current_user даёт вошедшего пользователя. db даёт сессию. Если вам нужны два предложения и оговорка, разделите зависимость или переименуйте её.
  • Каждая зависимость должна возвращать одну вещь, а не набор всего подряд. Функция вроде get_context(), которая отдаёт конфиг, пользователя, flags и доступ к БД, один раз экономит набор текста, а потом каждый день съедает ясность.
  • Имена должны совпадать между слоями. Если маршрут называет объект current_user, auth-зависимость и сервис не должны называть его account, principal или actor. Совпадающие имена уменьшают умственную нагрузку.
  • Тесты маршрута должны оставаться небольшими. Если ради одного endpoint'а нужно подменять настройки, auth, БД, логирование и два хелпера, значит маршрут зависит от слишком большого количества скрытой работы.

Именно здесь многие паттерны внедрения зависимостей в FastAPI либо остаются чистыми, либо превращаются в головоломку. Маршрут должен честно говорить о том, что ему нужно. Читатель не должен гадать, проверяет ли зависимость права, открывает ли транзакцию, загружает ли feature flags или ещё и вызывает другой сервис в стороне.

Мне нравится жёсткий критерий: новый коллега должен объяснить каждый параметр меньше чем за 30 секунд. Если не может, исправьте имя, сократите зависимость или вынесите лишнюю логику в обычную Python-функцию до merge.

Что стоит почистить дальше

Начните с малого. Полный рефакторинг обычно делает кодовую базу FastAPI хуже, прежде чем станет лучше. Выберите один загруженный router и перепишите два маршрута с одинаковой формой зависимостей.

Если один маршрут принимает настройки, auth и сессию базы данных, второй должен использовать такой же порядок и те же имена, если только у вас нет очень веской причины делать иначе. Такое повторение после шестого месяца помогает больше, чем хитрая абстракция.

Напишите командное правило, которое помещается на полстраницы:

  • настройки приходят из одной app-level зависимости
  • auth остаётся на уровне маршрута или в одной тонкой зависимости для конкретного маршрута
  • доступ к базе использует один и тот же паттерн сессии для чтения и записи
  • имена зависимостей остаются простыми и очевидными

Это правило выглядит почти слишком простым, но оно останавливает расползание архитектуры. Через несколько недель люди перестают гадать, нужно ли импортировать настройки напрямую, прятать auth внутри сервиса или открывать сессию в хелпере, который лежит в пяти файлах отсюда.

Потом вместе проверьте три вещи: конструкторы, имена зависимостей и настройку тестов. Они влияют друг на друга. Если конструктор сервиса требует шесть входов, маршрут, скорее всего, тоже будет казаться перегруженным. Если имена зависимостей расплывчатые, тесты унаследуют ту же путаницу. Если ради базового маршрута нужна тяжёлая подготовка, границы у вас, вероятно, расставлены не там.

Понятные паттерны внедрения зависимостей в FastAPI выглядят немного скучно, и это обычно хороший знак. Новый разработчик должен открыть маршрут и сразу понять, откуда берётся конфиг, кто проверил пользователя и когда открывается база данных.

Если ваш код FastAPI уже запутался, Oleg Sotnikov может помочь с точечным архитектурным ревью или Fractional CTO support. Короткий внешний разбор часто находит те две-три привычки, из-за которых чистые маршруты снова превращаются в головоломку.