Рефакторинг Python-кода: как безопасно выносить функции в зависимости и сервисы
Пошагово разберём декомпозицию: функции → сервисы → слои, сохраняя тестируемость и управляемость зависимостей. Дадим практические паттерны для миграций без простоя.
Содержание
Рефакторинг Python-кода: как безопасно выносить функции в зависимости и сервисы
Когда Python-проект растёт, “простые” функции постепенно превращаются в комок неявных зависимостей. Сегодня метод делает бизнес-логику и заодно ходит в БД, логирует, читает конфиг и дергает внешний API. Завтра вы добавляете к нему ещё один параметр “на всякий случай”, а послезавтра обнаруживается, что тесты не покрывают важные ветки — потому что их сложно поднять без настоящих сервисов.
Типичная цель рефакторинга звучит просто: разнести код по границам ответственности, чтобы бизнес-логика жила отдельно от инфраструктуры. Но на практике это превращается в инженерную задачу с рядом рисков: сломать контракт функций, потерять тестируемость, “утопить” конфигурацию в вызовах, а миграцию провести так, что релиз придёт без стабильности.
Ниже — пошаговый, проверенный подход к декомпозиции функции → сервисы → слои с сохранением управляемости зависимостей и возможности миграции без простоя. Фокус — на том, как делать это в Python системно, а не “на глаз”.
Почему “выносить функции” нужно не только ради красоты
На ранних этапах функции в Python — отличный способ быстро проверять гипотезы. Проблема не в самих функциях, а в смешении контекстов:
- Бизнес-правила (что должно произойти) смешиваются с внешними эффектами (как это сделать).
- Набор зависимостей становится “скрытым”: внутри функции создаются клиенты БД/HTTP, читаются переменные окружения, вызывается логирование, делаются ретраи.
- Тесты начинают требовать реальные инфраструктуры или сложные мок-обвязки на уровне модулей.
В результате возникают симптомы:
- трудно воспроизводить ошибки в тестах;
- изменение внешнего API ломает бизнес-код;
- новые фичи требуют копировать/дублировать логику или добавлять параметры “вглубь”.
Рефакторинг становится способом вернуть контроль: зафиксировать границы, определить, кто владеет зависимостями, и упростить тестирование до уровня “unit”, а не “интеграции”.
Ментальная модель: зависимости, сервисы и слои
Перед тем как трогать код, важно договориться о ролях.
Зависимости (dependencies)
Это компоненты, которые предоставляют доступ к внешним ресурсам или к инфраструктурным операциям:
- репозитории (работа с БД),
- клиенты HTTP,
- файловые системы,
- генераторы токенов,
- объект доступа к кэшу.
Зависимости должны иметь понятный интерфейс и минимум “магии”. Часто их реализуют адаптеры.
Сервисы (services)
Сервисы — это координаторы бизнес-действий. Они используют зависимости, но не должны знать деталей инфраструктуры:
- “Создать заказ” — сервис, который вызывает репозиторий и политику ценообразования.
- “Синхронизировать пользователей” — сервис, который вызывает внешний API и сохраняет результат.
Сервис — это место, где соединяются зависимости и бизнес-правила.
Слои (layers)
Слои — более крупные границы в приложении:
- API/Controller (принимает запросы, валидирует, вызывает сервисы),
- Domain (ядро бизнес-логики; иногда выделяют отдельно),
- Application/Use cases (часто тут живут сервисы),
- Infrastructure (реализации зависимостей: БД, HTTP, очереди).
В Python-реализациях эти понятия могут быть не “чистыми архитектурными слоями”, но логика должна сохраняться: слой выше не должен разбираться в том, как устроен слой ниже.
Пошаговый план рефакторинга без боли
Ниже — последовательность шагов, которая обычно работает в проектах с разным уровнем тестового покрытия.
Шаг 1. Инвентаризация: что делает функция на самом деле
Начните с описания существующей функции. Вам нужно выделить:
- Публичный контракт: входы/выходы, исключения, побочные эффекты.
- Состав работы:
- вычисления/правила,
- чтение/запись данных,
- внешние вызовы,
- логирование/метрики,
- работа с временем/идентификаторами.
Полезный прием: выписать “эффекты” на отдельную схему. Например:
calculate_prices(order)— только математика?- или внутри есть
db.fetch_product()иhttp.post(...)?
Если побочных эффектов много, не пытайтесь вынести всё сразу. Сначала зафиксируйте поведение.
Практика: зафиксируйте контракт тестом “до рефакторинга”
Если тестов нет — добавьте хотя бы характеристики:
- вход → ожидаемый результат,
- исключение при ошибке,
- вызовы зависимостей (если легко замокать).
Даже минимальные тесты — страховка, чтобы рефакторинг не превратился в переписывание.
Шаг 2. Разделите “чистую” логику и “эффекты”
Если функция смешивает бизнес и инфраструктуру, сделайте промежуточный слой:
- “чистая” функция/фрагмент: принимает данные → возвращает результат,
- “обвязка” вокруг: занимается внешними вызовами.
Это ключевой шаг: вы создаёте точку, где становится проще тестировать без зависимостей.
Пример: было
# app/pricing.py
def apply_discount(order_id: int, discount_code: str) -> float:
product_price = db.get_product_price(order_id) # эффект
user = http.get_user(order_id) # эффект
discount = discount_engine.calculate(discount_code, user) # бизнес
db.log_discount_applied(order_id, discount) # эффект
return product_price - discount
В этом коде бизнес-правило размазано по инфраструктуре. Тестировать его сложно.
Пример: стало (промежуточный разрез)
# app/pricing_domain.py
def compute_discount(price: float, user_attributes: dict, discount_code: str) -> float:
discount = discount_engine.calculate(discount_code, user_attributes)
return price - discount
И отдельно:
# app/pricing_service.py
def apply_discount(order_id: int, discount_code: str) -> float:
product_price = db.get_product_price(order_id)
user = http.get_user(order_id)
result = compute_discount(product_price, user, discount_code)
db.log_discount_applied(order_id, result)
return result
Теперь “domain”-часть проще покрывать юнит-тестами. А инфраструктура пока остаётся в обвязке — это нормально на данном шаге.
Шаг 3. Введите зависимости как параметры, а не как глобальные импорты
Главная причина хаоса в Python-коде — глобальные зависимости:
- модуль с БД создаётся в момент импорта,
- HTTP клиент живёт где-то “внутри модуля”,
- конфиг читается в процессе импорта.
Чтобы управлять ими, начните с простой декомпозиции: передавайте зависимости параметрами функций/конструкторов.
Пример: плохой паттерн
import db
import http
def apply_discount(order_id: int, discount_code: str) -> float:
product_price = db.get_product_price(order_id)
user = http.get_user(order_id)
...
Пример: лучше
from dataclasses import dataclass
from typing import Protocol
class PricingRepository(Protocol):
def get_product_price(self, order_id: int) -> float: ...
def log_discount_applied(self, order_id: int, result: float) -> None: ...
class UserClient(Protocol):
def get_user(self, user_id: int) -> dict: ...
@dataclass(frozen=True)
class PricingDependencies:
repo: PricingRepository
user_client: UserClient
def apply_discount(order_id: int, discount_code: str, deps: PricingDependencies) -> float:
product_price = deps.repo.get_product_price(order_id)
user = deps.user_client.get_user(order_id)
result = compute_discount(product_price, user, discount_code)
deps.repo.log_discount_applied(order_id, result)
return result
Важный нюанс: Protocol/интерфейсы дают возможность подменять зависимости тестовыми реализациями без “моков по строкам”.
Шаг 4. Соберите сервис: один класс отвечает за use-case
На этом шаге вы преобразуете “функцию-обвязку” в сервис. Сервис инкапсулирует:
- зависимости,
- транзакции (если есть),
- бизнес-оркестрацию (какие шаги и в каком порядке),
- политику обработки ошибок.
Пример: сервис
class PricingService:
def __init__(self, deps: PricingDependencies) -> None:
self._deps = deps
def apply_discount(self, order_id: int, discount_code: str) -> float:
product_price = self._deps.repo.get_product_price(order_id)
user = self._deps.user_client.get_user(order_id)
result = compute_discount(product_price, user, discount_code)
self._deps.repo.log_discount_applied(order_id, result)
return result
Теперь сервис — точка входа для бизнес-действия. Все внешнее спрятано в зависимости.
Шаг 5. Выведите “слой API” и не дайте инфраструктуре утечь вверх
Если есть web-фреймворк (FastAPI/Flask/Django), не допускайте, чтобы в контроллере:
- происходили прямые вызовы к БД/HTTP,
- выбиралась реализация репозитория,
- строилась сложная цепочка зависимостей.
Контроллер должен:
- валидировать входные данные,
- вызвать сервис,
- перевести доменные исключения в HTTP-ответы.
Пример: контроллер (FastAPI-стиль)
from fastapi import APIRouter, Depends
router = APIRouter()
@router.post("/discount")
def apply_discount_endpoint(payload: dict, pricing_service: PricingService = Depends(get_pricing_service)):
return {"result": pricing_service.apply_discount(payload["order_id"], payload["code"])}
Где get_pricing_service() поднимает реализации зависимостей из контейнера/конфига.
Это снижает связность: бизнес остаётся тестируемым без HTTP.
Управление зависимостями: практичные паттерны для Python
1) Контейнер зависимостей (composition root)
Рекомендуемый принцип: создавайте зависимости в одном месте — “composition root” (обычно в startup модуля приложения). Это снижает хаос и упрощает тестирование.
В Python часто используют:
- ручное создание в startup,
- DI-провайдеры фреймворка,
- light-weight контейнеры.
Суть не в инструменте, а в границе: сборка зависимостей должна быть сверху, а не внутри домена.
2) Инверсия зависимостей через Protocol
Protocol + dataclass позволяет выразить контракт зависимостей без жестких привязок.
Плюсы:
- легко создать фейк-репозиторий для тестов,
- минимальная магия,
- прозрачные интерфейсы.
3) Минимизируйте “псевдо-инъекции”
Не делайте так:
- сервис принимает “db_session” и “config” и “http_client” и “logger” — и вы снова получаете кухню в одном классе.
Лучше инкапсулировать в зависимости:
repo,user_client,clock,transaction_manager.
Так вы сохраняете стабильность интерфейса сервиса.
Тестируемость после декомпозиции: что именно тестировать
Важно не просто “разнести по папкам”, а привести тесты к адекватным уровням.
Unit-тесты для чистой логики
Там, где есть compute_discount и прочие расчёты без эффектов.
Тесты сервиса с поддельными зависимостями
Сервис можно тестировать, подставив фейковые реализации Protocol.
Пример фейков
class FakeRepo:
def __init__(self):
self.prices = {1: 100.0}
self.logs = []
def get_product_price(self, order_id: int) -> float:
return self.prices[order_id]
def log_discount_applied(self, order_id: int, result: float) -> None:
self.logs.append((order_id, result))
class FakeUserClient:
def get_user(self, user_id: int) -> dict:
return {"segment": "vip"}
И тест сервиса:
def test_apply_discount_calls_dependencies():
repo = FakeRepo()
user_client = FakeUserClient()
deps = PricingDependencies(repo=repo, user_client=user_client)
service = PricingService(deps)
result = service.apply_discount(1, "VIP10")
assert result == 90.0
assert repo.logs
Интеграционные тесты — отдельно
Реальные БД и HTTP — для сценариев “вместе”. Но бизнес-ядро не должно требовать их при каждом тесте.
Миграции без простоя: как менять код в проде осторожно
Рефакторинг часто запускают, когда “нельзя остановить сервис”. Значит, нужен подход, который выдержит частичную миграцию: старый и новый код могут сосуществовать.
Стратегия 1: Adapter-слой между старой функцией и сервисом
Вы оставляете старый API (функцию), но внутри делаете “прокладку” в новый сервис.
Пример: старую функцию не ломаем
# старый контракт остаётся:
def apply_discount(order_id: int, discount_code: str) -> float:
deps = build_pricing_deps() # временно: из composition root
service = PricingService(deps)
return service.apply_discount(order_id, discount_code)
На этом этапе:
- вы не меняете места вызовов,
- постепенно переводите потребителей на прямой сервис,
- затем убираете старую функцию, когда всё готово.
Риск: не создавать зависимости “на каждый запрос” без необходимости. Лучше вынести создание в composition root.
Стратегия 2: Постепенная подмена зависимостей
Если проблема в инфраструктуре, можно мигрировать репозиторий или клиент постепенно:
- сначала делаете новый интерфейс,
- затем реализуете адаптер к старой БД/HTTP,
- и только потом переключаете реальные реализации.
Стратегия 3: Режим “dual run”
Для критичных вычислений иногда запускают параллель:
- новый сервис считает результат,
- старый продолжает быть “истиной”,
- расхождения логируются, метрики собираются.
Это полезно, когда вы не полностью уверены, что рефакторинг не повлиял на бизнес-правила. Но включать dual run нужно осторожно по стоимости и чувствительности к внешним эффектам.
Подводные камни, которые встречаются почти всегда
1) Ошибка №1: выносить в сервис “как было”, но сохранить побочные эффекты внутри домена
Сервис — это уже шаг вперёд, но если вы продолжаете вызывать БД/HTTP прямо в доменных сущностях, вы не получили выгоды. Правильная граница: доменная функция должна быть чистой (или почти чистой), сервис — организует эффекты.
2) Скрытые зависимости через “час/рандом/текущее время”
Если логика зависит от времени (deadline, TTL, “сегодня”), передавайте clock как dependency.
Иначе тесты станут нестабильными.
3) Размытый интерфейс зависимостей
Protocol должен быть небольшим. Если зависимость разрослась в “богатую” штуку, вы получаете реальную связность обратно.
Лучшее правило:
- сервис просит конкретные операции, которые ему нужны,
- репозиторий реализует их локально.
4) Плохая обработка ошибок
При рефакторинге часто “теряют” типы исключений. Договоритесь заранее:
- какие доменные ошибки нужно нормализовать (например,
NotFound,ValidationError), - какие — оборачивать в инфраструктурные.
Потом это напрямую влияет на то, насколько предсказуемый API у сервиса.
5) Непоследовательная структура модулей
Если сегодня бизнес-логика в app/utils.py, а завтра часть сервисов в services/ и часть в domain/, вы получите “архитектурный зоопарк”.
Ориентируйтесь на принцип: границы ответственности отражаются структурой папок и именами.
Практический рецепт: функция → сервис → слои за 6 шагов
Короткая схема, которую можно повторять для каждой функции с инфраструктурными эффектами:
- Сделайте контракт явным: что функция возвращает и какие эффекты делает.
- Вынесите чистую логику в отдельные функции/модули (domain).
- Передайте зависимости параметрами (Protocol + dataclass/конструктор).
- Инкапсулируйте оркестрацию в сервис: один use-case — один сервис.
- Изолируйте API-слой: контроллер валидирует и вызывает сервис.
- Сделайте миграцию через адаптеры: не ломайте существующие вызовы, переключайте постепенно.
Как выбрать уровень декомпозиции: не делать “слишком архитектурно”
Есть соблазн превратить каждый use-case в “чистую архитектуру” с десятком папок и абстракций. Для Python-проектов это может быть избыточно, особенно если команда небольшая.
Ориентиры:
- если бизнес-логика тестируется — декомпозиция правильная;
- если
Комментарии
Пока нет комментариев