Python service layer: основы оформления бизнес-логики на примере Django-приложений
Django — отличный фреймворк, но он, на самом деле, толком не дает, да и не должен давать, ответ на вопрос, каким образом лучше всего хранить вашу бизнес-логику. Хранение бизнес-логики в моделях или views имеет множество недостатков, которые обычно начинают проявляться при росте кодовой базы проекта. Чтобы решить эти проблемы, разработчики часто начинают искать способы выделения бизнес-логики в своем приложении.
В этой статье я хотел бы попробовать дать стартовую точку на пути выделения слоя бизнес-логики у себя в приложениях и навести на новые мысли тех разработчиков, которые считают выделение этого слоя в своих приложениях чем-то излишним.
Так же хочу обратить внимание, что цель данной статьи не в том, чтобы дать правила, которым требуется слепо следовать, но в том, чтобы указать направление. Сервисный слой и в принципе его наличие, это такая вещь, которую нужно адаптировать под нужды вашей команды, компании и бизнеса.
На самом деле, изложенный далее текст относится не только к Django-проектам. Разрабатывая веб-приложения, используя другие инструменты, вроде Flask, люди используют те же концепции веб-разработки, причём часто именно в таком же виде, как они реализованы, в Django — views, request-response объекты, middlewares, модели, формы.
Как обычно обстоят дела в реальных Django-проектах?
Большинство начинающих разработчиков на Django, после прохождения официального туториала, зачастую начинают интересоваться, собственно, а где и как им хранить код? Вариантов много, но типичный Django-разработчик, скорее всего, найдет для себя ответ в знаменитой книге «Two Scopes of Django», заключающийся в следующем принципе: «Fat Models, Utility Modules, Thin Views, Stupid Templates».
Со временем данный подход стал дефолтным в Django-сообществе. И этот подход, несомненно, работает. Какое-то время.
В реальности, большинство крупных проектов представляют из себя не просто CRUD-приложения, а нечто большее. Более менее серьезный бизнес-процесс подразумевает за собой манипулирование несколькими сущностями, какие-то промежуточные операции и прочее.
Следуя данному принципу, в какой-то момент роста проекта вырисовывается весьма интересная картина.
Эти модели слишком толстые
На самом деле, считаю правило «толстых моделей» корнем всех проблем. Самые простые бизнес-процессы, как правило, взаимодействуют только с одной сущностью. Но с ростом этих сущностей в одном бизнес-процессе у разработчика возникает следующая дилемма: «Ок, я написал огромную простыню кода, которая использует несколько моделей. В какую из моделей мне положить всё это добро?».
В худшем случае разработчик оставит весь код в модели, импортируя модели друг в друга или вынося некоторые куски кода в некие другие модули, затем снова импортируя их эти же в модели. Добро пожаловать в кольцевые импорты! В вырожденных случаях разработчик может просто начать складировать код всех моделей и их логики в один файл, получая на выходе один models.py
на 1000+ строк кода.
Но чаще всего бизнес-логика начинает плавно перетекать во views.
Кажется, наши тонкие views становятся не такими уж и тонкими
Авторы «Two Scopes of Django» сами же пишут, что хранение логики во views — плохая идея, и вводят в своей книге главу «Trimming Models», в рамках которой поясняют, что при росте модели логику стоит выносить в слой, который они называют «Utility Modules».
Однако, реальность довольна сурова. Бизнес-логика перетекает во views бесконтрольно, потому что никто точно не может сказать, когда наша thin view стала не такой уж и thin. А писать код во views быстро и просто. Там сразу и request-объект со всеми данными, и формы, которые эти данные отвалидировали.
Впоследствии размер view начинает постепенно расти и в коде начинают появляться интересные ситуации, например, использование элемента глобального состояния view, такого как request-объект, прямо в бизнес логике, что намертво прибивает эту бизнес-логику к этой view.
Разработчикам удобно использовать глобальное состояние view прямо в бизнес-логике. Некоторые даже не брезгуют доклеивать к нему свои промежуточные вычисления. Это всегда затрудняет чтение кода. Это делает невозможным переиспользование данной бизнес-логики в других местах проекта, таких как celery-задачи, или management-команды. В конце концов это мешает вам адекватно покрыть ваш код unit-тестами. Единственной точкой тестирования в этом случае остается ваш view.
На этом этапе бизнес-логика становится сильно привязанной к инфраструктурному слою (фреймворк/библиотека) вашего проекта.
Каша из Utility Modules
Utility Modules — одна из самых недопонятых концепций в мире Django. Что именно мы должны там хранить? Многие из нас начинают складировать там мусор.
Часто я наблюдаю, что люди даже не пытаются как-то формализовать правила к этому слою в своих приложениях. Все называют его по-разному. Кто-то создаёт в корне проекта файлик utils.py
, кто-то создаёт модули helpers
или processors
. А кто-то — всё это одновременно.
В этих модулях часто могут лежать как и элементы бизнес-логики, так и какие-то универсальные утилиты. Одним словом, каша.
Сервисный слой
Основа концепции сервисного слоя закладывается в понимании разницы между бизнес-логикой и инфраструктурой. Часто разработчики склонны путать эти понятия, поэтому давайте дадим им определения.
Бизнес-логика — это код реализации бизнес-правил. Например, логика списаний с одного баланса и начисление на другой. Слой бизнес-логики является отображением процессов реального мира на программный код.
Код инфраструктуры — это код реализации инфраструктурных процессов, манипулирующий в основном искусственными понятиями, сформулированными сугубо для разработки ПО, например: вью, модели, фреймворки, субд, валидация входных параметров, реквесты, респонсы, API и тд.
Что такое сервисный слой? Давайте тоже попробуем дать определение. Сервисный слой — это такой набор компонентов программного кода, которые инкапсулируют в себе логику приложения. Такие компоненты мы и будем называть сервисами бизнес-логики или доменными сервисами.
Бизнес-логика в сервисном слое стремится быть максимально изолированной от нашей инфраструктуры, которая представляет из себя детали реализации фреймворка и других внешних зависимостей, и быть максимально близкой к предметной области. Я специально использую слово стремится вместо должна, потому что разработчики должны самостоятельно решать, какой уровень изоляции им требуется.
Если говорить в терминах Django, то целью сервисного слоя будет вынести весь код, относящийся к бизнес-логике приложения, из моделей, view и всех остальных мест, где её быть не должно, вроде middlewares или management-команд, в отдельные модули, оформленные по определенным правилам, оставляя в инфраструктурном слое лишь вызовы из этих модулей.
В рамках сервисного слоя можно выделить также инфраструктурные сервисы. Дело в том, что полностью вынести какие-то инфраструктурные операции за пределы бизнес-логики далеко не всегда возможно. Например, такие операции, как общение с СУБД, запись/чтение диска, хождение по сети. Их-то и можно изолировать в инфраструктурных сервисах, чтобы затем использовать в своей бизнес-логике c помощью более высокоуровневого интерфейса.
Инфраструктурные сервисы здорово помогают читать бизнес-логику, так как скрывают детали реализации работы с вашей инфраструктурой, а также упрощают написание тестов бизнес-логики, так как один сервисный эндпоинт гораздо проще подменить на mock, чем набор низкоуровневых операций.
Таким образом, сервисом в самом общем понимании является базовый блок кода, слоя бизнес-логики, либо инфраструктурного слоя, но не одновременно.
Сервисный слой на уровне структуры проекта
С чего же можно начать формирование сервисного слоя? Начнём с самого простого — структуры модулей. Следует договориться о создании отдельных модулей на уровне каждого Django-приложения. В данной статье в качестве примера будем называть такие модули services:
project_name
├── app_name
│ ├── services
│ │ └── ...
│ ├── tests
│ ├── views
│ ├── models
...
Вы также можете сделать один глобальный модуль сервисов, если у вас, например, мало django-приложений, словом, отталкивайтесь от своего проекта.
Внутри services будут храниться наши модули сервисов. Каждый отдельный модуль сервисов хранит в себе какой-то один бизнес-процесс, объединённый тесным контекстом. Модуль сервиса на уровне структуры проекта может состоять как из одного py-файла, так и из сложного набора подмодулей, организованного исходя из требований вашей бизнес-логики. Соблюдайте баланс, старайтесь не класть несколько сервисов используемых в рамках одного бизнес-процесса в один файл:
services
├── __init__.py
├── complicated_service_example
│ ├── __init__.py
│ └── ...
└── simple_service_example.py
При оформлении сервиса в виде модуля старайтесь явно отражать его публичный API в __init__.py
, туда должны быть импортированы только сущности, предназначенные для публичного использования.
Также структуру сервисного слоя можно оформить исходя из разделения на инфраструктурные сервисы и сервисы бизнес-логики.
services
├── __init__.py
├── example_with_separated_service_layers
│ ├── __init__.py
│ ├── infrastructure
│ │ └── ...
│ ├── domain
│ │ └── ...
...
Сервисный слой на уровне кода
Проектируя код сервисного слоя, в голове следует держать главное правило:
В сервисном слое следует изолировать бизнес-логику от инфраструктуры.
Проектируя сервисный слой, вы должны заставить работать свой фреймворк на свою бизнес-логику, а не наоборот, как это часто происходит в Django-приложениях. Это значит, что вы не должны использовать в коде сервисов бизнес-логики такие вещи, как request-объекты, которые принимают views, формы, celery-задачи, различные сторонние библиотеки, отвечающие за инфраструктурные задачи и т. д. Это правило очень важно. И весь подход к написанию кода отталкивается именно от него. В местах, когда вынести из пайплайна бизнес-логики инфраструктуру невозможно, требуется должным образом её изолировать. Способы изоляции будут описаны далее.
На уровне кода типичный сервис бизнес-логики представляет из себя разновидность паттерна Command, который является производной от паттерна Method Object.
Для примера придумаем простой сервис с минимальным количеством бизнес-логики. Допустим, требуется периодически готовить отчёт о пользователях в системе. Нужно взять все username
пользователей, затем сохранить их в csv-файл, имя которого должно быть сгенерировано определенным образом.
Создадим в директории services
файл make_users_report_service.py
со следующим содержимым:
import csv
from datetime import datetime
from typing import List
from django.contrib.auth.models import User
class MakeUsersReportService:
def _extract_username_list(self) -> List[str]:
return list(User.objects.values_list('username', flat=True))
def _generate_file_name(self) -> str:
return '{}-{}'.format('users_report', datetime.now().isoformat())
def _load_username_list_to_csv(self, file_name: str, username_list: List[str]) -> str:
full_path = f'reports/{file_name}.csv'
with open(full_path, 'w', newline='') as csv_file:
writer = csv.writer(csv_file)
writer.writerow(['username'])
writer.writerows(
([username] for username in username_list)
)
return full_path
def execute(self) -> str:
username_list = self._extract_username_list()
file_name = self._generate_file_name()
return self._load_username_list_to_csv(file_name, username_list)
Такой сервис в виде класса является базовым строительным блоком сервисного слоя. Давайте внимательно рассмотрим его особенности:
1) Используются аннотации типов
Аннотации типов и их чекеры вроде mypy — одно из самых значимых нововведений Python за последние годы. Аннотации типов в сервисном слое важны, в первую очередь из-за того, что помогают следовать правилам инфраструктурного слоя.
Если методы вашего сервиса возвращают или принимают инфраструктурные объекты, к примеру, из Django, то это является явным маркером того, что вы что-то делаете не так.
Аннотации типов также помогают отразить неявные зависимости внутри кода, так как вам придется импортировать типы, объекты которых не создаются в вашем коде напрямую, если вы хотите использовать их в качестве аргументов или возвращаемых значений методов.
2) В коде единственная публичная точка входа
Обратите внимание на метод execute
— он является единственной публичной точкой входа в данном сервисе. Всё, что он делает — вызывает под капотом приватные методы в определенном порядке. Такая схема позволяет проще читать код — открыв сервис, сразу видно, в какой последовательности вызывается логика и как её вызывать.
Данный подход помогает в проектировании сервисов. Помните, если вам хочется сделать несколько публичных методов подобных execute
в одном сервисе, то это явный признак того, что скорее всего логику нужно разделить и вынести в отдельный сервис.
3) Изолированы обращения к СУБД
Такое инфраструктурное действие, как обращение к СУБД, изолировано в отдельном методе — _extract_username_list
. В приватном методe вы можете заменить, к примеру, вызов values_list
на raw-sql запрос, или вообще, обращение к некому внешнему API, но при этом ваша бизнес-логика, которой просто нужен список из юзернеймов, всё равно не изменится. Мы зафиксировали интерфейс в примитивах языка.
В данном примере нужны только username-ы пользователей, и поэтому мы можем ограничиться встроенными типами Python — List[str]
и не возвращать список объектов модели, ведь Django ORM является такой же частью инфраструктуры как и всё остальное. Более сложные кейсы мы рассмотрим чуть позже.
Выбор, конечно, за вами, но помните, используя queryset-ы напрямую в коде вашей бизнес-логики, вы усложняете себе написание unit-тестов, ведь замокать queryset-ы гораздо сложнее чем просто метод, возвращающий примитив языка. Использование интеграционных тестов Django, которые позволяют вам поднять под капотом тестовую СУБД, важно, но отрабатывают такие тесты гораздо дольше, чем обычные unit-тесты, которые её не используют. А в случае если источником данных выступает некий внешний API стороннего сервиса, то и выбора по сути не остаётся — надо мокать его.
Для тестирования бизнес-логики не нужно каждый раз использовать тестовую базу данных. Ведь для бизнес-логики неважно откуда пришли данные — из СУБД ли, из файла на диске или внешнего API.
Для себя я вывел такую формулу: если возможно, покрывайте ваш сервис на 70% юнит-тестами с замоканным источником данных и на 30% интеграционными тестами, которые умеют поднимать вашу инфраструктуру, частью которой и является ваш источник данных в виде СУБД. Такая практика сделает приятнее разработку по TDD, а также ускорит прохождение тестов в вашем CI/CD пайплайне, что тоже не может не радовать.
4) Изолированы обращения к коду формирования csv-файла
Запись информации в файл на диске — такая же инфраструктурная задача. В принципе, все тезисы, что я перечислил в 3 пункте про СУБД, подходят и сюда — проще тестировать, не надо менять остальной код в случае, если понадобится писать, к примеру, exel файл вместо csv.
5) Название сервиса отражает конкретное действие
Название сервиса должно отражать некое конкретное действие. Размытое название сигнализирует о том, что код выполняет более чем одно конкретное действие, что является нежелательным для сервисов бизнес-логики.
Только что мы рассмотрели пример очень простого сервиса, состоящего из трёх простых этапов. Бизнес-логики в этом сервисе было весьма мало. В реальности сервисы, конечно же, гораздо сложнее. Давайте рассмотрим, какие проблемы у вас могут возникнуть с кодом выше и как их можно решить в рамках сервисного слоя.
Value Object, DTO, DAO
Давайте представим, что теперь в наш отчёт нужно писать не только username-ы пользователей, но так же и их emal-ы, имена, id и прочее. В рамках сервисного слоя мы стремимся отгородить бизнес-логику от инфраструктуры. А это значит, что вернув список из объектов Django-модели, мы отступим от наших принципов.
Тогда, часть кода выше, отвечающая за запрос к СУБД можно модифицировать следующим образом (реализацию остальных методов пока опустим):
...
from dataclasses import dataclass
from typing import Generator
from django.contrib.auth.models import User
@dataclass
class UsersReportRow:
pk: int
username: str
email: str
is_active: bool
class UsersReportService:
def _extract_users_data(self) -> Generator[UsersReportRow]:
for user in User.objects.all():
yield UsersReportRow(
pk=user.pk,
username=user.username,
email=user.email,
is_active=user.is_active,
)
...
Метод _extract_username_list
мы заменили на _extract_users_data
, который теперь возвращает генератор с Value Object-ами, реализованный в виде дата-класса. Дата-классы — весьма полезный инструмент в разработке на Python.
Часто питонисты пренебрегают создавать классы просто для передачи данных и могут использовать в подобных ситуациях, к примеру, список из dict-ов, что является худшей альтернативой. Достоинство классов перед dict-ами состоит в фиксированной схеме данных, которая находится у вас в коде. Такую фиксированную схему данных удобнее использовать в аннотациях типов. При вызове метода, который возвращает пользовательский объект вместо dict, отпадает потребность идти и каждый раз вспоминать его содержимое — ваша IDE сможет помочь вам, когда вам потребуется обратиться к атрибутам.
Иногда под Value Object имеют в виду другой паттерн — DTO — Data Transfer Object. С моей точки зрения, DTO — сущность общения между сервисами, в то время как Value Object является сущностью общения внутри сервиса.
Если представить, что наш сервис , помимо пути к сгенерированному отчёту, должен будет вернуть ещё какую-либо информацию, например, уникальный id отчёта, это могло бы выглядеть так:
...
from dataclasses import dataclass
@dataclass
class UsersReportDTO:
full_path: str
report_id: str
class UsersReportService:
...
def execute(self) -> UsersReportDTO:
...
return UsersReportDTO(
full_path=full_path,
report_id=report_id,
)
Чаще всего, помимо чтения данных, может понадобится и много других операций с хранилищем: создание, обновление данных, агрегация. Как оставить сервис простым и не засорять бизнес-логику кодом работы с хранилищем?
Ответ — изолировать работу с вашим хранилищем через DAO — Data access object. Вот так может выглядеть DAO для взаимодействия с таблицей пользователей в Django:
from dataclasses import dataclass
from typing import Iterable
from django.contrib.auth.models import User
@dataclass
class UserEntity:
pk: int
username: str
email: str
is_active: bool
class UsersDAO:
def _orm_to_entity(self, user_orm: User) -> UserEntity:
return UserEntity(
pk=user_orm.pk,
username=user_orm.username,
email=user_orm.email,
is_active=user_orm.is_active,
)
def fetch_all(self) -> Iterable[UserEntity]:
return map(self._orm_to_entity, User.objects.all())
def count_all(self) -> int:
...
def update_is_active(self, users_ids: Iterable[int], is_active: bool) -> None:
...
Таким образом, в коде сервиса вашей бизнес-логики остаётся только использование DAO без построения запросов к СУБД напрямую.
Хочу ещё раз обратить внимание — мы специально не используем объекты моделей Django. Заставляя двигать данные внутри нашего сервисного слоя через примитивы языка и пользовательские объекты, мы изолируем нашу бизнес-логику от инфраструктуры.
Изолирование работы с ORM в коде обычно вызывает больше всего негативных эмоций у Django-разработчиков. Думаю, это происходит в первую очередь из-за того, что очень часто доменная модель бизнес-сущностей накладывается на модели Django в начале разработки новой фичи.
Может быть, такая изоляция и выглядит избыточной, когда в примере можно сделать всего один простой запрос в ORM, но поверьте, если для бизнес-логики вам в какой-то момент потребуется не просто достать набор строк из таблицы, а собрать некий агрегат, делая несколько запросов в разные источники данных, или сделать raw-sql запрос через драйвер к СУБД, который возвращает вместо адекватного объекта какой-то tuple из tuple-ов, то помимо всех достоинств, описанных ещё в предыдущем более простом примере, вы получите ещё и улучшенную в разы читаемость кода.
Так же весьма важным эффектом является то, что изолируя ORM, если в какой-то момет вам придётся заменить источник данных, к примеру, на MongoDB, или вообще, веб-API вашего другого сервиса, вам не придётся переписывать кучу unit-тестов и всю бизнес-логику. Нужно будет только переписать тесты на ваш DAO или метод сервиса, который возвращает DTO/Value Object. Главное лишь то, чтобы ваш код изолирующий работу с данными сохранял старый интерфейс. Сделайте вашу инфраструктуру зависимой от бизнес-логики, а не наоборот!
Dependency injection — построение комплексных сервисов
Чаще всего, нам требуется писать сложные пайплайны бизнес-логики, и в таких случаях, при помещении всего кода в один сервис, он становится плохо читаемым. Тогда требуется отделять сложные части пайплайна в отдельные сервисы. Таким образом, сервисы могут быть комплексными и использовать под капотом другие сервисы, как инфраструктурные, так и доменные.
Теперь давайте снова усложним наш пример. Представим, что заказчик захотел, чтобы помимо csv-файла была так же возможность сгенерировать и exel-файл.
Создадим два новых инфраструктурных сервиса, затем встроим их в наш сервис бизнес-логики. Один из них будет отвечать за запись xlsx, а второй — за csv.
Модифицируем исходный файл make_users_report_service.py
(снова опустим подробности реализации):
...
import abc
from typing import Iterable
class IReportFileAdapter(abc.ABC):
@abc.abstractmethod
def create_report_file(self, file_name: str, username_list: Iterable[UsersReportRow]) -> str:
pass
...
class UsersReportService:
def __init__(self, report_file_adapter: IReportFileAdapter):
self.report_file_adapter = report_file_adapter
def _extract_users_data(self) -> Generator[UsersReportRow]:
...
def _generate_file_name(self) -> str:
...
def execute(self) -> str:
username_list = self._extract_users_data()
file_name = self._generate_file_name()
return self.report_file_adapter.create_report_file(file_name, users_data_list)
И далее, создадим новый файл с инфраструктурными сервисами, реализованными в виде адаптеров report_file_adapters.py
:
import abc
from typing import Iterable
from .make_users_report_service import IReportFileAdapter
class CSVReportFileAdapter(IReportFileAdapter):
def create_report_file(self, file_name: str, username_list: List[str]) -> str:
...
class XLSXReportFileAdapter(IReportFileAdapter):
def create_report_file(self, file_name: str, username_list: List[str]) -> str:
...
Таким образом, вызывать сервис бизнес-логики вы сможете с разными адаптерами, в зависимости от того, какой файл вам нужно сгенерировать.
from your_django_app.services.reports import (
UsersReportService,
CSVReportFileAdapter,
)
users_report_service = UsersReportService(report_file_adapter=CSVReportFileAdapter())
path_to_report = users_report_service.execute()
Давайте рассмотрим написанный код подробнее:
1) Inversion of Control
С помощью абстрактного базового класса мы эмулировали интерфейс в нашем коде и положили его именно рядом с нашей бизнес-логикой, а не адаптерами. Таким образом, мы смогли достичь Инверсии управления в нашем коде. Благодаря этому в файл с бизнес-логикой не нужно делать импорты инфраструктурных сервисов, которые умеют писать csv/xlsx. Мы добились зависимости инфраструктурного слоя от бизнес-логики, а не наоборот.
Вы так же можете достичь IoC с помощью типа Protocol, появившегося в typing начиная с python 3.8.
Многие считают IoC в python излишним. Применяйте по ситуации: если вам не нужна высокая степень изоляции компонентов, можно обойтись и без IoC, либо использовать абстрактные базовые классы с реализацией, которые уже следовало бы держать рядом с адаптерами в моём примере. Сервисный слой гибок.
2) Удобный mock
Одному мне не нравится каждый раз писать в тестах длинный @mock.patch('...')
? Длинный путь к объекту, который вы хотите замокать, банально неудобно читать и прописывать. К тому же, при перемещении или переименовании объекта, который вы мокаете, эту строку нужно будет не забыть поправить. Теперь всё можно сделать гораздо приятнее. Встраивая сервисы друг в друга, при написании тестов, вы получаете возможность пробрасывать Mock при инициализации вашего сервиса:
from mock import Mock
from unittest import TestCase
from your_django_app.services.reports import (
UsersReportService,
IReportFileAdapter,
)
class UsersReportServiceTestCase(TestCase):
def test_example(self):
report_file_adapter_mock = Mock(spec=IReportFileAdapter)
users_report_service = UsersReportService(
report_file_adapter=report_file_adapter_mock
)
...
...
Используя внедрение зависимостей, вы можете строить сложные пайплайны бизнес-логики из большого количества ваших рабочих блоков с кодом — сервисов.
О любви Python-сообщества к библиотекам и зависимости от инфрастурктуры
Что делают в python-сообществе когда возникает проблема? Правильно — пишут новую awesome-библиотеку (и теперь у нас две проблемы). Идея сервисного слоя не нова, и библиотеки на эту тему уже есть.
https://github.com/mixxorz/django-service-objects — автор предлагает писать сервисный слой на основе Django-форм. С моей точки зрения, валидация входных данных должна происходить в инфраструктурном слое, а не перегружать собой слой бизнес-логики. На слое сервисов можно валидировать только бизнес-правила. А всякие проверки, что число — это число, а не строка, лучше оставить обычным Django-формам.
https://github.com/dry-python/stories — ещё одна библиотека для построения сервисного слоя. Давно слежу за ребятами. Помимо stories, у них много других интересных библиотек на разные темы.
Я против использования подобных библиотек. И вот почему:
Сторонняя библиотека — это уже часть инфраструктуры, сама по себе. Завязывая вашу бизнес-логику на такую библиотеку, вы заранее завязываете свою бизнес-логику на инфраструктуру, что уже противоречит идеи разделения бизнес-логики и инфраструктуры на разные слои. Однажды вам может понадобится выйти за рамки вашей библиотеки для написания бизнес-логики, и, скорее всего, это будет больно. Сильно задумайтесь, нужно ли вам это.
У многих в голове стереотип, что Python — язык для data science и каких-то DevOps-скриптов, и без дополнительных приседаний в виде специальных библиотек, нормально оформить в нём сложную бизнес-логику нельзя. Естественно, это не правда. Python давно готов для написания серьезных приложений с нагруженной бизнес-логикой. В этой статье я привёл примеры разных приёмов, которые не требовали сторонних зависимостей для описания бизнес-логики. Если вам захочется выйти за рамки описанного в статье, вы будете ограничены лишь языком программирования, а не какой-то библиотекой.
Сервисный слой должен оставаться гибким, и подходить потребностям вашей команды. Имейте это в виду, если всё-таки решитесь завязать ядро вашего приложения, которым является ваша бизнес-логика, на какую-то библиотеку.
Что дальше?
Лучше всех по теме негативных последствий от зависимости бизнес-логики от инфраструктуры приложения уже высказывался Роберт Мартин в своих статьях «Screaming Architecture» и «The Clean Architecture», а также прекрасной книге «Clean Architecture: A Craftsman’s Guide to Software Structure and Design».
В принципе, если возвести все приемы, которые я показал в статье, в абсолют, и везде использовать DI и IoC, а также ещё пару приёмов, то у вас получится своя реализация чистой архитектуры.
Вы могли видеть в данной статье элементы Domain-driven design. Для ознакомления рекомендую обратить внимание на книгу «Domain-Driven Design: Tackling Complexity in the Heart of Software» от Эрика Эванса. Сервисный слой можно также развить в полноценное DDD приложение.
Подводя итог
Итак, подведём итог нашему путешествию в сервисный слой:
Начните со структуры — заведите отдельный модуль для вашего сервисного слоя и хорошо структурируйте его.
Сервисы бизнес-логики оформляются в виде классов-команд (классов с одним публичным методом). Инфраструктурные — по ситуации.
Трафик данных в сервисах, а также между ними, лучше всего осуществлять в примитивах языках и пользовательских объектах с помощью Value Object, DTO, DAO.
Больше тестов! Покрывайте сервисы тестами, активно мокайте инфраструктуру и другие сервисы от которых вы зависите.
Сервисы могут быть комплексными и вызывать под капотом другие сервисы. Такое взаимодействие будет удобно организовать через Dependency Injection.
Сервисный слой должен подходить потребностям вашей команды. Опасайтесь завязки на чужие решения в виде библиотек или фреймворков. Используйте только те приёмы, что вы считаете нужными. Возможно, просто вынесение кода из ваших views, без серьезной изоляции инфраструктуры, может решить большинство ваших проблем.