Январь — самое удобное время разобрать завалы в проекте. Пол‑команды ещё в отпусках, pull‑реквестов меньше, product owner'ы только вспоминают, что планировали делать в этом году — можно спокойно пройтись по коду и навести порядок.

В этой статье пойдёт речь о нескольких косметических действиях, которые, с одной стороны, почти не затрагивают логику программы и не вызывают ненависти у тестировщиков, а с другой — делают код чуть приятнее и дают темы для обсуждения на бэкенд‑созвонах. Мы разложим импорты, перенесём логику из роутов в контроллеры, а из контроллеров — в репозитории и сервисы, избавимся от requirements.txt в пользу нормального менеджера зависимостей и включим mypy.

1. Чистим роуты: логика уходит в контроллеры

Сейчас роуты (ручки, эндпоинты) часто содержат всю бизнес‑логику: и походы в БД, и обращения к внешним сервисам, и расчёты, и валидацию.
Цель: вынести бизнес‑логику из роутов в контроллеры, а в самих роутах оставить только приём запроса, проброс данных в контроллер и возврат ответа: либо удачного, либо с HTTP‑ошибкой.
Как сделать безопасно: здесь задача не в том, чтобы оптимизировать работу программы или менять алгоритмы, а лишь в том, чтобы разложить код по слоям. Поэтому берём текущий код роута, аккуратно вырезаем из него «мясо» (походы в БД, сеть, расчёты) и переносим 1:1 в метод контроллера. Роут становится тоньше, контроллер - грязнее, но это нормально: на следующем шаге как раз будем чистить контроллеры.

До:

# app/routers/users.py

from litestar import get
from litestar.di import Provide
from litestar.exceptions import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db import get_db
from app.models import User


@get("/users/{user_id:int}")
async def get_user(user_id: int, db: AsyncSession = Provide(get_db)) -> dict:
    # 1. Достаём пользователя из БД прямо в роуте
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(404, "User not found")

    # 2. Тут же могли бы что‑то ещё посчитать… но пока просто отдаём dict
    return {"id": user.id, "name": user.name, "email": user.email}

После:

# app/routers/users.py

from litestar import get
from litestar.di import Provide
from litestar.exceptions import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession

from app.controllers.user_controller import UserController
from app.db import get_db


@get("/users/{user_id:int}")
async def get_user(user_id: int, db: AsyncSession = Provide(get_db)) -> dict:
    result = await UserController(db).get_user(user_id)
    if result is None:
        raise HTTPException(404, "User not found")
    return result
# app/controllers/user_controller.py

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models import User


class UserController:
    def __init__(self, db: AsyncSession):
        self.db = db

    async def get_user(self, user_id: int) -> dict | None:
        # логика целиком перенесена из роута
        result = await self.db.execute(select(User).where(User.id == user_id))
        user = result.scalar_one_or_none()
        if not user:
            return None

        return {
            "id": user.id,
            "name": user.name,
            "email": user.email,
        }

Что изменилось после чистки роута

  • В роуте осталась только логика самого HTTP‑слоя: принять запрос, передать данные дальше в контроллер и вернуть ответ или HTTP‑ошибку.

  • Из файла с роутами исчезли импорты CRUD/моделей и прочей бизнес‑логики, остались только типы и зависимости (например, AsyncSession и контроллер).

  • В контроллере теперь живёт логика приложения, но без HTTP‑обвязки: никаких HTTPException, только работа с данными.

Что можно улучшить дальше:

  • Заменить «сырые» dict в ответах на Pydantic‑модели, чтобы получить нормальную схему и автодокументацию.

  • Прокидывать контроллер через Provide, чтобы роуты вообще не импортировали SQLAlchemy и жили только на уровне «контроллер + DTO».

2. Чистим контроллеры: БД + сеть уходят репозитории и сервисы

После первого шага контроллеры превращаются в свалку всего: они ходят в БД, вызывают сторонние сервисы (внутренние и внешние), что‑то считают и решают, какой ответ отдать. Контроллер при этом становится тяжёлым и плохо тестируемым.
Цель: превратить контроллер в оркестратор. Он знает, в каком порядке вызвать операции, но не занимается напрямую ни SQL, ни HTTP‑запросами. Для этого выносим работу с БД в репозитории, а сетевое общение - в сервисы.
Как сделать безопасно: берём текущий код контроллера и не меняя логику, просто выделяем из него два слоя:

  • всё, что общается с БД (db.executedb.query), переносим в репозитории;

  • всё, что ходит по сети (httpxrequests и т.п.), переносим в сервисы.

Контроллер остаётся тем же по поведению, просто делает это через методы репозитория и сервиса.

До:

# app/controllers/user_controller.py

import httpx
from sqlalchemy import select

from app.config import BILLING_KEY, BILLING_URL
from app.models import User, UserAction


class UserController:
    def __init__(self, db):
        self.db = db

    async def update_user_balance(self, user_id, amount):
        # 1. БД
        user = self.db.query(User).filter(User.id == user_id).first()
        if not user:
            return None

        # 2. Сеть (POST в биллинг)
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{BILLING_URL}/users/topup",
                json={"user_id": user_id, "amount": amount},
                headers={"Authorization": f"Bearer {BILLING_KEY}"}
            )
        billing_data = resp.json()

        # 3. БД снова
        user_action = UserAction(user_id=user_id, action="TOPUP_BALANCE")
        self.db.add(user_action)
        await self.db.commit()
        return {"balance": billing_data["balance"]}

После:

# app/controllers/user_controller.py

from app.config import BILLING_KEY, BILLING_URL
from app.repositories.user_repo import UserRepository
from app.repositories.user_action_repo import UserActionRepository


class UserController:
    def __init__(self, db):
        self.db = db
        self.user_repo = UserRepository(db)
        self.user_action_repo = UserActionRepository(db)
        self.billing_service = BillingService(
            base_url=BILLING_URL, 
            api_key=BILLING_KEY
        )

    async def update_user_balance(self, user_id, amount):
        user = await self.user_repo.get_by_id(user_id)
        if not user:
            return None
        
        billing_data = await self.billing_service.topup(user_id, amount)
        await self.user_action_repo.create(user_id, "TOPUP_BALANCE")
        await self.db.commit()
        return {"balance": billing_data["balance"]}
# app/repositories/user_repo.py

from sqlalchemy import select

from app.models import User

class UserRepository:
    def __init__(self, db):
        self.db = db

    async def get_by_id(self, user_id):
        result = await self.db.execute(select(User).where(User.id == user_id))
        user: User | None = result.scalar_one_or_none()
        if not user:
            return None
        return user


# app/repositories/user_action_repo.py

from sqlalchemy import select

from app.models import UserAction


class UserActionRepository:
    def __init__(self, db):
        self.db = db

    async def create(self, user_id, action):
        user_action = UserAction(user_id=user_id, action=action)
        self.db.add(user_action)
# app/services/billing_service.py

import httpx

class BillingService:
    def __init__(self, base_url, api_key):
        self.base_url = base_url
        self.api_key = api_key

    async def topup(self, user_id, amount):
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{self.base_url}/users/topup",
                json={"user_id": user_id, "amount": amount},
                headers={"Authorization": f"Bearer {self.api_key}"}
            )
        return resp.json()

На что обратить внимание

  • db.commit() (если он есть) можно пока оставить в контроллере - задача этого шага не в том, чтобы менять транзакционную схему, а в том, чтобы разнести код по слоям при максимально прежнем поведении.

Что изменилось после чистки контроллера

  • В контроллере осталась только последовательность шагов: «получить пользователя», «обновить баланс через биллинг», «создать лог действия», «вернуть результат».

  • Весь SQL‑код и детали работы с сессией уехали в репозиторий

  • Весь сетевой код уехал в сервис.

Что можно улучшить дальше:

  • Добавить интерфейсы/протоколы для репозиториев и сервисов, чтобы в тестах подменять их заглушками.

  • Вынести повторяющиеся паттерны (ретраи, логирование, метрики) в базовый класс для сервисов, чтобы не копировать один и тот же retry + httpx по всему проекту.

3. Переходим на UV, Poetry или похожий менеджер зависимостей

Если у вас до сих пор лежат requirements.txt и requirements_dev.txt, самое время заменить их на нормальный менеджер зависимостей с lock‑файлом: uvPoetryPipenvили аналог.

Цель: перестать вручную поддерживать списки пакетов и версий, получить воспроизводимое окружение и явное разделение основных и dev‑зависимостей.

Что сделать на практике:

Самый прямой вариант миграции — взять текущие requirements*.txt и импортировать из них зависимости: всё, что было в requirements.txt, превратить в обычные зависимости, а из requirements_dev.txt — в dev‑группу. На практике в requirements.txt почти всегда лежит не только ваш верхнеуровневый список, но и половина транзитивных зависимостей, однажды туда попавших через pip freeze. Чтобы не тащить этот мусор в новый инструмент, удобнее сначала пройтись по проекту и собрать реальные импорты (importfrom ... import ...) небольшим одноразовым скриптом, а уже по результату добавить нужные пакеты в uv или Poetry.

Переход на новый менеджер затрагивает сборку: вместо pip install -r requirements.txt в Dockerfile и CI появятся команды вида uv syncpoetry install или экспорт зависимостей перед билдом. Даже если Dockerfile лежит рядом и технически вы можете всё поменять сами, лучше заранее обсудить изменения с devops‑инженером: часто есть дополнительные скрипты и пайплайны, которые тоже завязаны на старые requirements.txt, и хорошо, если команда будет к этому готова.

4. Упорядочим импорты через isort

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

Добавляем пакет:

poetry add --group dev isort

Добавляем настройки:

# pyproject.toml

[tool.isort]
profile = "black"
line_length = 88

Запускаем:

poetry run isort . --check-only  # проверить
poetry run isort .               # пофиксить

После включения isort импорт‑блоки во всём проекте становятся единообразными: стандартная библиотека, затем сторонние пакеты, затем ваши модули - и всё внутри групп отсортировано по алфавиту. Новым людям проще вникать в код, ревью становится чуть легче, а автозапуск isort перед коммитом убирает вечные мелкие конфликты «я добавил импорт сверху, а ты — снизу».

Помимо разового запуска по проекту, isort имеет смысл «прикрутить» к процессу разработки:

  • Добавить проверку в CI: вместо ручного запуска - отдельный шаг пайплайна, который выполняет isort . --check --diff и падает, если импорты разъехались. Так порядок импортов перестаёт быть предметом споров на ревью и просто становится правилом сборки.

  • Подключить isort к pre-commit: локальный хук автоматически будет приводить импорты в порядок перед каждым коммитом. В результате меньше шума в диффах, меньше правок «чисто из-за импортов» и меньше шансов словить конфликт только потому, что два человека вставили импорты в разном месте.

Важно не превращать isort во внезапное наказание для коллег. И добавление проверки в CI, и включение хуков в pre-commit лучше сначала обсудить со всей backend‑командой, чтобы у людей не появилось ощущения, что правила поменялись «по-тихому». На первом шаге можно сделать разовый прогон isort только по тем файлам, за которые вы отвечаете сами, показать результат на общем звонке и уже там вместе решить: хотите ли вы такого порядка импортов во всём проекте и как именно будете его включать - через CI, pre-commit или как-то ещё.

5. Добавляем mypy

Со временем даже в аккуратном проекте накапливаются «дыры» в типах: где‑то не указали тип аргумента, где‑то возвращаемое значение превратилось в Any, где‑то сигнатура уже не совпадает с реальностью. Всё это годами живёт в коде, пока однажды кто‑то не меняет функцию «по ощущениям» и не ловит баг в проде. mypy как раз про то, чтобы включить над проектом статический прожектор: он не меняет поведение программы, но позволяет увидеть эти места заранее и начать их постепенно закрывать.

Добавляем пакет:

poetry add --group dev mypy

Добавляем настройки:

# pyproject.toml

[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true

Запускаем:

poetry run mypy .

После подключения mypy вы получаете отчёт с честным списком проблем: неаннотированные функции, неявные Any, сомнительные ветки с None и прочие участки, где типы уже не совпадают с тем, что реально происходит в коде. Это отличный источник задач на «январский уход за проектом»: можно договориться в команде хотя бы о простом правиле - каждый разработчик исправляет по 5–10 ошибок mypy на ту часть кода, с которой он работает. Так за пару недель типизация становится ощутимо лучше, а изменения - заметно безопаснее.

С mypy легко попасть в ловушку: если ни разу его не запускали, первый прогон покажет десятки тысяч ошибок, и после этого есть риск «никогда больше его не запускать». Поэтому имеет смысл заходить постепенно.

  1. Запускать mypy вручную «по настроению»
    Можно периодически запускать mypy по всему проекту и исправлять столько ошибок, на сколько хватает сил в конкретный день. Главное правило — не пытаться «починить всё за раз», а выносить по чуть‑чуть, например 5–10 ошибок за рабочую сессию.

  2. Проверять только изменённые файлы
    Проще всего настроить проверку не на весь проект, а на файлы, которые изменились относительно основной ветки. Пример для локального запуска:

    CHANGED=$(git diff --name-only origin/main...HEAD -- '*.py')
    poetry run mypy $CHANGED 2>&1 | grep -E "$(echo "$CHANGED" | tr '\n' '|' | sed 's/|$//')"
    

    Так mypy будет вызываться только для изменённых файлов. Нужно понимать нюанс: если вы чуть‑чуть поправили огромный файл на 10 тысяч строк, ошибки он покажет по всему файлу, а не только по диффу.

  3. Следить за общим числом ошибок в CI
    В CI можно ловить строку вида Found 8 errors in 3 files (checked 5 source files), вытаскивать из неё количество ошибок и сравнивать с эталоном (например, с текущим состоянием main). Если число ошибок выросло - сборку считать неуспешной и просить автора MR либо починить новые ошибки, либо хотя бы не добавлять новые. Такой подход хорошо работает как мягкое правило: «старые долги живут, но новых не создаём»

Ровно как с isort, отдельно стоит проговорить и организационный момент. Перед тем как включать проверки mypy в CI, имеет смысл обсудить это с командой: сколько ошибок сейчас считаем допустимыми, что делаем с существующими «долгами» и с какого момента считаем правило «новых ошибок не добавляем» обязательным. Так mypy не станет внезапным источником красных пайплайнов, а будет восприниматься как заранее согласованное улучшение процесса.

Выводы

Понятно, что за неделю не получится «зарефакторить» весь проект, который годами рос как придётся, даже если речь только о мелочах вроде роутов, импортов и mypy. К тому же границы ответственности никуда не деваются: если /users - зона вашей команды и вы можете спокойно править там, то лезть в соседние условные /posts без договорённостей - гарантированный способ собрать негатив от коллег.

Зато всегда можно начать со своей части кода: применить эти приёмы к «своим» ручкам и модулям, а потом, когда люди вернутся из отпусков, показать результат на бэкенд‑созвоне. Возможно, команде понравится, и пункты из статьи - чистые роуты, контроллеры с репозиториями и сервисами, isort и mypy - постепенно станут общепринятым стандартом и превратятся из январской инициативы в планомерное погашение техдолга. Год пролетает очень быстро, и без таких небольших, но регулярных шагов даже простой порядок в структуре проекта можно так и не успеть навести.

Как вы начинаете год: с небольшой чистки проекта или сразу с новых фич? Расскажите, что собираетесь сделать в январе со своим бэкендом.