
Оглавление
Введение
Здравствуйте! Это вторая часть из серии статей «Сервисы — место, где живет бизнес логика». Если Вы еще не знакомы с первой частью, то рекомендую начать с нее, чтобы у вас сложилась общая картина. Сегодня мы постараемся ответить на все оставшиеся вопросы: познакомимся с прекрасной, легковесной DI-библиотекой, научимся «инжектить» в Django, посмотрим на несколько дашбордов в Кибане и поговорим про доменные модели.
Шпаргалка: Код из первой статьи
Чтобы у Вас под рукой была информация из предыдущей части, продублирую здесь код, на котором мы остановились:
Сервисы с декоратором
@dataclassи одним публичным методом__call__.
Скрытый текст
@dataclass(kw_only=True, slots=True, frozen=True)
class BuyProductService:
price: int
amount: int
@log_service_error
def __call__(self) -> SoldProductDTO:
if self.amount < self.price:
raise NotEnoughBalanceError(price=price, amount=amount)
# ...other business logic...
return SoldProductDTO(message="Thank you for byuing!")
Декоратор
@log_service_error, который по желанию можно вешать на методы__call__:
Скрытый текст
def log_service_error(__call__: Callable) -> Callable:
@wraps(__call__)
def wrapper(self, **kwargs) -> Any:
try:
return __call__(self, **kwargs)
except BaseServiceError as error:
logger.error(
{
"error_in": self.__class__.__name__,
"error_name": error.__class__.__name__,
"error_message": error.message,
"error_context": dict(**error.context),
},
)
raise error
return wrapperБазовый класс исключений
BaseServiceError, от которого должны наследоваться все сервисные исключения:
Скрытый текст
class BaseServiceError(Exception):
def __init__(self, message: str = None, **context) -> None:
self.message = message or self.__doc__
self.context = context
class ProductNotAvailable(BaseServiceError):
"""Product not available now"""
class OutOfStockError(BaseServiceError):
"""Product is out of stock"""Как сервисные ошибки обрабатывались на уровне всего приложения:
Скрытый текст
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import exception_handler
from apps.core.service import BaseServiceError
def service_exception_handler(exc, context):
if isinstance(exc, BaseServiceError):
return Response(
data={
"error": exc.message,
"detail": dict(**exc.context),
},
status=status.HTTP_400_BAD_REQUEST,
)
return exception_handler(exc, context)Dependency Injection — Punq
Мы перебрали большое количество возможных DI фреймворков — почти все из них выглядели очень неудобно. И, думаю, это не проблема конкретных фреймворков. Просто при разработке Django, изначально никто не закладывал возможность использования DI в привычном смысле этого слова. Фреймворк имеет свое видение того, как нужно организовывать код, и идти против него — значит постоянно сталкиваться с сопротивлением. Но все же у нас получилось найти подход, который достаточно гладко укладывается в существующую архитектуру Django.
«Назвался punq-ом —
живи...будь готов, что тебя никогда не выдаст Google по запросу «Depencdency Injection in Python». Но мы нашли.
Кроме шуток — библиотека идеально вписалась в нашу концепцию: она простая и легковесная. Если вы вдруг с ней не сталкивались, по ходу статьи вы поймете то, как она работает. Но если есть время — можете бегло пройтись по документации, лишним точно не будет.
Реализация
Разбираться будем на том же самом примере бизнес логики — есть условный товар, его можно купить. Для этого у нас есть BuyProductView, где вызывается BuyProductService— там и находится наша бизнес-логика.
Итак, предлагаю, что называется, пойти «от противного» и немного усложнить ситуацию. Представим, что:
У нас нет никакого DI фреймворка.
На вход наш сервис ожидает DTO-объект. То есть, перед тем как вызвать сервис, этот DTO нужно где-то создать.
Первое, что приходит в голову, это написать код так:
class BuyProductView(APIView):
permission_classes = (CustomerRequired,)
def post(self, request: Request) -> Response:
serializer = BuyProductSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
result = BuyProductService(
product=BuyProductIn(**serializer.data)
)(customer=request.user.customer)
return Response(
data=result,
status=status.HTTP_200_OK,
)
Посмотрите внимательно на этот код, какие у него есть проблемы?
Первое — жесткая связь между классами. Наша BuyProductView знает слишком много о внутреннем устройстве BuyProductService: если вдруг, вместо BuyProductIn, у сервиса изменится конструктор, и нужно будет передавать, например, BuyProductInV2, то нам неизбежно придется вносить изменения в код этой «вьюшки». Это не очень хорошая история, особенно, если в коде сервис используется писят два раза, и везде потребуется такая замена.
Второе — мы рискуем нарваться на циклические импорты. Конечно, при правильной организации кода такой риск сводится к минимуму, но все же не к нулю. В Django это достаточно распространенная история, и, наверно, каждый, кто писал на этом замечательном фреймворке, сталкивался с такой проблемой.
Серебряной пулей здесь является объект Container с возможностью использования фабрик. Ниже пример, как это работает:
@final
@dataclass(kw_only=True, slots=True, frozen=True)
class BuyProductService:
product: BuyProductIn
@log_service_error
def __call__(self, *, customer: Customer) -> BuyProductOut:
...
def buy_product_service_factory(product: dict) -> BuyProductService:
return BuyProductService(product=BuyProductIn(**product))
container.register("BuyProductService", factory=buy_product_service_factory)
Здесь мы регистрируем сервис под ключом "BuyProductService" и указываем способ его получения через фабричную функцию buy_product_service_factory. Заметьте, что фабричная функция принимает просто какой-то dict, а уже внутри функции создается DTO-объект BuyProductIn. Минус строковой регистрации — теряем некоторые возможности IDE.
Хочу подчеркнуть, что сама библиотека рекомендует использовать абстракции для регистрации зависимостей. Так это может выглядеть:
class AbstractBuyProductService(abc.ABC):
@abc.abstractmethod
def __call__(self, *, customer: Customer) -> BuyProductOut:
"""Выполняет процесс покупки товара для указанного клиента.
Args:
customer: Объект клиента, совершающего покупку.
Returns:
Результат операции покупки, содержащий детали заказа.
Raises:
CustomerNotActiveError: Если клиент не активен.
InsufficientFundsError: Если недостаточно средств.
ProductNotAvailableError: Если товар отсутствует на складе.
"""
@final
@dataclass(kw_only=True, slots=True, frozen=True)
class BuyProductService(AbstractBuyProductService):
product: BuyProductIn
@log_service_error
def __call__(self, *, customer: Customer) -> BuyProductOut:
...
def buy_product_service_factory(product: dict) -> BuyProductService:
return BuyProductService(product=BuyProductIn(**product))
container.register(AbstractBuyProductService, factory=buy_product_service_factory)
Это заставляет писать отдельный класс-абстракцию под каждую реализацию сервиса и немного раздувает кодовую базу. Однако в абстрактном классе вы можете «навалить» солидный док-стринг, в котором объясните, как работает ваш сервис.
Решение о том, какой из вариантов использовать — компромиссное. Если ваш домен сложен/специфичен я бы порекомендовал не отказываться от абстракций, хотя они и добавляют накладных расходов при написании кода.
Итак, вернемся к нашему примеру. Как теперь выглядит «вьюха»:
class BuyProductView(APIView):
permission_classes = (CustomerRequired,)
def post(self, request: Request) -> Response:
serializer = BuyProductSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
service = container.resolve("BuyProductService", product=serializer.validated_data)
result = service(customer=request.user.customer)
return Response(
data=result.asdict(),
status=status.HTTP_200_OK,
)
И последний, исключительно технический штрих, чтобы все заработало — необходимо импортировать ваши сервисы в методе ready, чтобы punq успел все зарегистрировать:
from django.apps import AppConfig
class ProductConfig(AppConfig):
name = "apps.product"
def ready(self):
from apps.product import services # <--- тут
Итог:
Во «вьюху» больше не нужно импортировать ни сам сервис, ни его входной DTO. Мы отдаем внутрь словарь, а под капотом происходит какая-то магия, которая здесь нас больше не волнует. Внутри самого сервиса мы все еще имеем понятный контракт в виде
BuyProductIn.Чтобы получить нужный сервис, следует просто указать тот ключ, под которым его регистрировали. Это можно сделать в любой точке вашего приложения, в том числе в другом сервисе.
Если изменить
BuyProductInнаBuyProductInV2«вьюха» об этом не узнает. Все инкапсулировано внутри фабричной функции.
Если сервис зависит от другого сервиса
Распространенная история, когда один сервис нужно использовать внутри другого сервиса. На самом деле, все очень просто. Допустим, что при покупке товара, необходимо передавать информацию в какую-то внешнюю систему, с которой мы вынуждены общаться через HTTP-запросы. Пусть внешняя система называется «CRM».
Ниже пример сервиса, который взаимодействует с «CRM» (детали реализации нас здесь не сильно интересуют):
@final
@dataclass(kw_only=True, slots=True, frozen=True)
class SendProductCRMService:
@log_service_error
def __call__(self, *, product: BuyProductIn) -> None:
...
def send_product_crm_service_factory() -> SendProductCRMService:
return SendProductCRMService()
container.register("SendProductCRMService", factory=send_product_crm_service_factory)
И вот как можно использовать его функционал внутри BuyProductService:
@final
@dataclass(kw_only=True, slots=True, frozen=True)
class BuyProductService:
product: BuyProductIn
crm_sender: SendProductCRMService
@log_service_error
def __call__(self, *, customer: Customer) -> BuyProductOut:
...
def _send_product_to_crm(self) -> None:
try:
self.crm_sender(product=self.product)
except BaseServiceError:
raise ProductPurchaseFailed(product=dict(id=self.product.id, ...))
def buy_product_service_factory(product: dict) -> BuyProductService:
return BuyProductService(
product=BuyProductIn(**product),
crm_sender=container.resolve("SendProductCRMService"),
)
container.register("BuyProductService", factory=buy_product_service_factory)
Конструктор — это публичный контракт класса. Если сервису для работы нужен SendProductCRMService, то эта зависимость должна быть явной. При этом ни сам сервис, ни его пользователи «понятия не имеют», как эта зависимость в него попадает — все решается на уровне фабрики.
Заметьте, что при работе с вложенным сервисом внутри метода основного сервиса, вы можете обрабатывать исключения первого. Это удобно, если, допустим, функционал отправки не так критичен, и мы не должны завершать на этом бизнес процесс, однако должны сделать дополнительные действия (например, уведомить менеджеров и т.п.). Напоследок дополню, что здесь не нужно логировать исключения, все уже реализовано в декораторе log_service_error.
Если же функционал критичен и бизнес процесс нужно завершить — создайте и вызовите здесь другое исключение, как показано в примере выше. Следует взять за правило, что каждый сервис описывает свой бизнес-процесс и должен «разговаривать» на своем языке исключений.
Доменные модели и их логика
Доменная логика = бизнес логика? В целом — да, но я бы хотел немного разделять эти понятия, в рамках нашего подхода это можно представить так:

Доменная логика — это правила и поведение, инкапсулированные внутри самих доменных объектов (сущностей, агрегатов). Её цель — обеспечить целостность и консистентность модели. Критерий для вынесения кода в доменную логику —его многократное использование в разных сервисах.
Бизнес-логика — это координация доменных объектов, внешних систем, транзакций и обработка специфичных ошибок для реализации конкретного сценария (use case). Она принадлежит сервисному слою (сервисам приложения).
Рассмотрим следующий пример:
@final
@dataclass(kw_only=True, slots=True, frozen=True)
class BuyProductService:
product: BuyProductIn
@log_service_error
def __call__(self, *, customer: Customer) -> BuyProductOut:
product = Product.objects.get(pk=self.product.id)
if customer.can_buy_max_count_of(product) < self.product.count:
raise NotEnoughBalance(
product=dict(id=product.pk, price=product.price),
customer=dict(id=customer.pk, balance=customer.balance),
)
return self._buy(product=product, customer=customer)
@transaction.atomic
def _buy(self, *, product: Product, customer: Customer) -> BuyProductOut:
...
У объекта customer есть метод can_buy_max_count_of, куда передается товар. Метод подсчитывает сколько таких товаров может купить данный заказчик. В результате возвращает просто int:
class Customer(models.Model):
balance = models.PositiveIntegerField("количество рублей на балансе")
...
def can_buy_max_count_of(self, product: Product) -> int:
if product.price > 0:
return self.balance // product.price
return 0
Определение количества доступных для покупки товаров конкретного заказчика — доменная логика модели Customer.
Или возьмем другой пример:
class Product(models.Model):
title = models.CharField("наименование товара", max_length=256)
price = models.PositiveIntegerField("цена товара")
count = models.PositiveIntegerField("остаток на складе", default=0)
status = models.PositiveSmallIntegerField(
"статус товара",
choices=ProductStatusEnum.choices(),
default=ProductStatusEnum.AVAILABLE,
)
@property
def is_available(self) -> bool:
return self.status == ProductStatusEnum.AVAILABLE and self.count > 0
Свойство is_available — доменная логика модели Product. Скорее всего это свойство вы будете использовать множество раз, и каждый раз прописывать эти проверки в сервисе — плохая идея.
Таким образом, доменная логика — частный случай бизнес логики, который удобно отнести к конкретным доменным моделям, над которыми эта логика производится.
Как правило, такой код вы будете часто переиспользовать в нескольких разных сервисах. Старайтесь, чтобы ваша доменная логика представляла из себя набор небольших методов. Правильно определяйте контекст доменной модели, так она «не раздуется» и останется, что, называется, «в хорошей форме».
Бонус или «При чем здесь ELK»?
Насколько сильно Вас тревожит, как в вашей команде пишутся логи? По своему опыту могу сказать, что на логи смотрят, только если «что-то сломалось» и нужно восстановить причинно-следственную связь.
Когда я учился в школе, я узнал про понятие «альтернативные издержки». Простыми словами: представьте, что вы стоите на развилке. Выбрав одну дорогу, вы навсегда отказываетесь от всех сокровищ и возможностей, которые таила в себе другая.
Подход команды к логированию — это одна из таких развилок, где разработчик выбирает, как ему относится к своему коду. Глобально есть два пути:
Код — прикладной инструмент. Просто реализует нужную бизнес-логику.
Код — прикладной и исследовательский инструмент. Может и должен служить объектом для анализа поведения пользователей и всей системы в целом.
Знаете ли вы, сколько раз пользователь, например, «не купил товар потому что у него не было денег на счете»? Или сколько раз «товара не было на складе»? А бизнес вообще знает, что он может собрать такую метрику почти бесплатно? И даже в динамике. А ведь может.
При этом, унифицированная структура лога положительно скажется на вашем поиске в Кибане. Напомню, что в нашем подходе для решения всех этих проблем используется декоратор, который «вешается» на сервисы
@final
@dataclass(kw_only=True, slots=True, frozen=True)
class BuyProductService:
product: BuyProductIn
@log_service_error # <--- тут
def __call__(self, *, customer: Customer) -> BuyProductOut:
...
И благодаря этому, мы можем строить, например, такие «дашики»:

А теперь представьте, что можно проводить аналитику для каждого сервиса. Ваш класс в понимании бизнеса становится не просто куском кода, а конкретным бизнес-процессом, с которого можно собирать метрики.
В каком-то смысле это UL в рамках сервисной логики: вы всегда будете говорить об одном и том же бизнес-процессе, имея в виду конкретный класс и его метрики. Вы можете изменить структуру лога под себя, я показал привел лишь пример. Добавьте то, что необходимо Вам и проводите нужную Вам аналитику.
«Потыкаться» с дашбордами можно в репозитории, там уже все настроено, как говорится, «из коробки».
Заключение: Собираем пазл
В этой и предыдущей статьях мы прошли путь от классических Django-паттернов к более структурированному подходу, который лучше масштабируется в сложных приложениях. Самое главное преимущество которое я вижу — можно начать использовать этот подход здесь и сейчас — с учетом всего что мы сказали, он простой и понятный. Вам не нужно перепиливать всю архитектуру, а бизнесу выделять огромные деньги на рефакторинг, чтобы начать внедрять сервисный слой. Давайте подытожим:
Что мы получили в итоге?
Аккуратные доменные модели с бизнес-логикой, но без ответственности за процессы.
Сервисы как координаторы бизнес-процессов с явными зависимостями.
DI-контейнер как инструмент борьбы с жесткими связями и циклическими импортами
Фабричные функции как способ инкапсуляции сложной логики создания объектов
Разделение ответственности, где каждый компонент делает одну вещь и делает её хорошо
Структурированное и унифицированное логирование для сервисных ошибок
Что дальше?
Этот подход открывает дорогу к:
Простому тестированию (зависимости легко подменяются моками)
Гибкой конфигурации (разные реализации для разных окружений)
Постепенному рефакторингу (можно внедрять поэтапно)
Я сознательно не стал углубляться в тему тестирования — она заслуживает отдельной статьи. Но поверьте, с таким подходом ваши тесты станут проще и надежнее. А пайп тестов на Гитлабе не будет занимать 20 минут.
Повторю, что я сказал в начале первой статьи «Представленный подход — не серебряная пуля». Это набор инструментов, которые стоит иметь в арсенале. Используйте их там, где это имеет смысл: в сложных бизнес-процессах, часто меняющихся требованиях, больших командах.
Если у вас есть вопросы, замечания или собственный опыт применения подобных подходов — жду вас в комментариях! Обмен опытом делает нас всех лучше.
Спасибо за внимание!

Комментарии (14)

Tishka17
12.01.2026 13:38Не раскрыта тема внедрения зависимостей. Ваш сервис по факту ни от чего не зависит - ни от адаптера для доступа к БД, ни от вспомогательных объектов, реализующих БЛ. Вместо этого почему-то он принимает продукт в init, а не как данные. Это немного странно
С другой стороны, у вас есть глобальный контейнер. Вы его используете напрямую, а не получаете сервисо во вьюхе.
Суммарно, это больше выглядит как паттерн ServiceLocator, а не Dependency Injection.
Что же касается самого punq, меня в нем в своё время остановила одна вещь - отсутствие явных скоупов. Вы не можете ему сказать, что всё время пока вы обращаетесь к контейнеру внутри обработчика запроса надо возвращать один и тот же экземпляр конкретного класса, а при выходе - почистить (иногда вызвав доп функции финализации).

youngWishes Автор
12.01.2026 13:38Приветствую
Не раскрыта тема внедрения зависимостей. Ваш сервис по факту ни от чего не зависит - ни от адаптера для доступа к БД, ни от вспомогательных объектов, реализующих БЛ.
Может я не совсем понял о чем речь — есть пример, где для работы сервиса нужно передать другой сервис который работает с CRM и инжектится внутри фабрики.
С другой стороны, у вас есть глобальный контейнер. Вы его используете напрямую, а не получаете сервисо во вьюхе.
Суммарно, это больше выглядит как паттерн ServiceLocator, а не Dependency Injection.
Я понимаю что вы имеете в виду — скорее всего использование контейнера внутри вьюхи. Да, эта часть выглядит как ServiceLocator и это единственное место где сервис инжектится подобным образом, но! По моему скромному мнению для джанги и его вьюшек - это допустимое поведение. Использование всяких декораторов над методом или переопределение конструктора вьюхи — совсем печально выглядит. Но и это относится только к вьюхам — остальные зависимости попадают через фабрику.
Что же касается самого punq, меня в нем в своё время остановила одна вещь - отсутствие явных скоупов. Вы не можете ему сказать, что всё время пока вы обращаетесь к контейнеру внутри обработчика запроса надо возвращать один и тот же экземпляр конкретного класса, а при выходе - почистить (иногда вызвав доп функции финализации).
Что есть — то есть. Насколько знаю в punq-е можно определять два поведения глобально для каждого объекта - singletone и создавать новый объект. Но при использовании это поведение определить на ходу нельзя. Однако для нас это не играет большой роли.
Спасибо, что прокомментировал, для меня это действительно важно.

Tishka17
12.01.2026 13:38На самом деле, хотелось бы услышать побольше про выбор контейнеров, с чем сравнивали, почему не подошли? Я соглашусь, что большинство их в питоне очень низкого качества, но у вас наверно своё мнение есть. В частности, у меня есть свой проект - dishka, который решает, например, задачу контроля скоупов. Рассматривали ли вы его?
Legendary8971
Статья неплохая, есть ряд вопросов:
1. container - это глобальный объект?
2. Можем ли иметь несколько контейнеров?
3. Насколько эффективно ресолвятся зависимости?
4. Не совсем понял пример с тем, что меняется входная схема. Да, мы не привязываемся к неймингу, отдаем сырые данные , там под капотом происходит магия. Но в таком случае получается, что мы ничего как клиент не знаем о контракте, если контракт поменяется мы бы в любом случае получили проблему совместимости, но когда явно указываешь схему передачи, ide подсветит тебе, что у тебя синус с косинусом не сходятся или ту же проблему получишь при запуске линтов. Как бы никто не защищен, если от того, что у тебя и исходную схему модернизируют, так что она перестанет работать у клиентов сервиса. Но тут мы это прячем на слой поглубже.
DI в целом очень хорош, нужен ли он в django проекте?
youngWishes Автор
Да
Технически — да, можно создавать контейнеры на уровне app-ки в Django. Вопрос насколько это будет удобно и не понадобится ли какой-то сервис из одной аппки в сервисе другой аппки. Можно легко запутаться если контейнеров много.
Я не проводил бенчмарк-тесты, но это одна из самых легких DI-либ + 100% покрытие тестами. Минимальный шанс, что где-то выстрелит.
4)
В моем примере я использовал
dictпотому что это было удобно в случае с сериализатором. Можно написать более явный контракт и передавать именнованные аргументы (а в фабрике соответственно их принимать и маппить в DTO):Это правда, что никто не защищен. Однако в данном случае при изменении исходной схемы мы не зависим от внутренней реализации сервиса. Что-то менять нужно и там, и там, однако, при использовании фабрики — многие вещи становятся гораздо удобнее. Взять то же версионирование сервисов — мы можем реализовать его в фабрике, и не выносить этот if-код наружу. Опять же - скрываем детали реализации.
Tishka17
2) Думаю, речь не про уровень аппки, а про то чтобы контейнер создавался при старте и передавался во вьюхи. Чтобы его можно было, в частности, в тестах подменить
4) все ещё не понимаю зачем вы передаете данные при резолвинге сервиса. Ну передайте вы при вызове его методов, предвариательно заложив в интерфейс. Если же это конфигурационные параметры - передавайте при настройке контейнера, а не во вьюхе опять же
youngWishes Автор
4) Думаю здесь вы правы и стоило прокинуть Product в метод call а не конструктор, а зависимости оставить на уровне конструктора, было бы более констистентно, спасибо.