Январь — самое удобное время разобрать завалы в проекте. Пол‑команды ещё в отпусках, 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.execute,db.query), переносим в репозитории;всё, что ходит по сети (
httpx,requestsи т.п.), переносим в сервисы.
Контроллер остаётся тем же по поведению, просто делает это через методы репозитория и сервиса.
До:
# 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‑файлом: uv, Poetry, Pipenvили аналог.
Цель: перестать вручную поддерживать списки пакетов и версий, получить воспроизводимое окружение и явное разделение основных и dev‑зависимостей.
Что сделать на практике:
Самый прямой вариант миграции — взять текущие requirements*.txt и импортировать из них зависимости: всё, что было в requirements.txt, превратить в обычные зависимости, а из requirements_dev.txt — в dev‑группу. На практике в requirements.txt почти всегда лежит не только ваш верхнеуровневый список, но и половина транзитивных зависимостей, однажды туда попавших через pip freeze. Чтобы не тащить этот мусор в новый инструмент, удобнее сначала пройтись по проекту и собрать реальные импорты (import, from ... import ...) небольшим одноразовым скриптом, а уже по результату добавить нужные пакеты в uv или Poetry.
Переход на новый менеджер затрагивает сборку: вместо pip install -r requirements.txt в Dockerfile и CI появятся команды вида uv sync, poetry 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 легко попасть в ловушку: если ни разу его не запускали, первый прогон покажет десятки тысяч ошибок, и после этого есть риск «никогда больше его не запускать». Поэтому имеет смысл заходить постепенно.
Запускать mypy вручную «по настроению»
Можно периодически запускатьmypyпо всему проекту и исправлять столько ошибок, на сколько хватает сил в конкретный день. Главное правило — не пытаться «починить всё за раз», а выносить по чуть‑чуть, например 5–10 ошибок за рабочую сессию.Проверять только изменённые файлы
Проще всего настроить проверку не на весь проект, а на файлы, которые изменились относительно основной ветки. Пример для локального запуска: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 тысяч строк, ошибки он покажет по всему файлу, а не только по диффу.Следить за общим числом ошибок в CI
В CI можно ловить строку видаFound 8 errors in 3 files (checked 5 source files), вытаскивать из неё количество ошибок и сравнивать с эталоном (например, с текущим состояниемmain). Если число ошибок выросло - сборку считать неуспешной и просить автора MR либо починить новые ошибки, либо хотя бы не добавлять новые. Такой подход хорошо работает как мягкое правило: «старые долги живут, но новых не создаём»
Ровно как с isort, отдельно стоит проговорить и организационный момент. Перед тем как включать проверки mypy в CI, имеет смысл обсудить это с командой: сколько ошибок сейчас считаем допустимыми, что делаем с существующими «долгами» и с какого момента считаем правило «новых ошибок не добавляем» обязательным. Так mypy не станет внезапным источником красных пайплайнов, а будет восприниматься как заранее согласованное улучшение процесса.
Выводы
Понятно, что за неделю не получится «зарефакторить» весь проект, который годами рос как придётся, даже если речь только о мелочах вроде роутов, импортов и mypy. К тому же границы ответственности никуда не деваются: если /users - зона вашей команды и вы можете спокойно править там, то лезть в соседние условные /posts без договорённостей - гарантированный способ собрать негатив от коллег.
Зато всегда можно начать со своей части кода: применить эти приёмы к «своим» ручкам и модулям, а потом, когда люди вернутся из отпусков, показать результат на бэкенд‑созвоне. Возможно, команде понравится, и пункты из статьи - чистые роуты, контроллеры с репозиториями и сервисами, isort и mypy - постепенно станут общепринятым стандартом и превратятся из январской инициативы в планомерное погашение техдолга. Год пролетает очень быстро, и без таких небольших, но регулярных шагов даже простой порядок в структуре проекта можно так и не успеть навести.
Как вы начинаете год: с небольшой чистки проекта или сразу с новых фич? Расскажите, что собираетесь сделать в январе со своим бэкендом.
