Комментарии 42
Братан, хорош, давай, давай, вперёд! Контент в кайф, можно ещё? Вообще красавчик! Можно вот этого вот почаще?
Единая ответственность. Каждый сервис реализует законченный кусок бизнес-логики — от простого создания объекта до комплексных операций с вызовами других сервисов или внешних API.
Единственный публичный метод. Вся работа с сервисом ведётся через метод
call. Избавляемся от проблемы когда разработчики начинают придумыватьrun-ы, execute-ы, process-ыи т.д.
Вы определенно двигаетесь в правильном направлении. Позвольте, сэкономлю Вам немного времени.
У Вас не сервис а команда и она Вам не нужна. Если Вам нужен класс с одной функцией
- это значит что класс здесь в принципе не нужен. Выкиньте ненужное, оставьте старую добрую функцию и все еще упроститься.
Единственный плюс, который вижу у подхода автора - что для входящих данных отдельный dto не нужен (т.к. уже есть init у dataclassa).
У класса есть как минимум 2 преимущества:
Возможность декомпозиции. При этом результат будет представлять единый юнит, а не россыпь разрозненных функций, гоняющих между собой набор одинаковых параметров.
Возможность применения DI фреймворков. Вот это та часть, которую как раз интересно было бы узнать в продолжении.
Если Вам нужен класс с одной функцией- это значит что класс здесь в принципе не нужен.
Спасибо за комментарий! Возможно, тут возникло недопонимание - в классе может быть несколько методов, просто публичный - только один - и это метод __call__.
Спасибо за комментарий! Возможно, тут возникло недопонимание - в классе может быть несколько методов, просто публичный - только один - и это метод
call.
Вы можете сделать хоть тысячу маленьких функций, а можете собрать все воедино и результат у вас будет один и тот же и изменится не может, потому что функция на самом деле одна а так как все остальные функции приватные то для пользователя Вашего класса их нет и быть не может.
Объект - это история про некое состояние, про применение методов к этому состоянию для его изменения или создания новых. У Вас, класс(объект) - это просто банка с одной функцией. Если Вы вытащите функцию из банки - ничего не поменяется но станет чуть чуть проще так как останеться разделение на простые функции и простые данные. Подход - в функцию передается и возвращается DTO сильно проще чем Ваш, не нужно объяснять кому-то что это вроде бы сервис но на самом деле команда с состоянием, а еще там может быть только одна функция и она должна быть такой то, не нужно объяснять не нужно следить за таким стилем чтобы никто не начал еще функции публичные писать, не нужно никого учить и объяснять почему класс должен имитировать функцию. Будьте проще - просто используйте функции и не нужно будет ничего объяснять и проверять.
У Вас, класс(объект) - это просто банка с одной функцией.
Пожалуйста объясните на основании чего вы сделали такой вывод? В статье нигде не сказано что класс должен содержать одну функцию.
Единственный публичный метод. Вся работа с сервисом ведётся через метод
call. Избавляемся от проблемы когда разработчики начинают придумыватьrun-ы, execute-ы, process-ыи т.д.
А в чем проблема ранов и прочих хэндлов?
А в чем проблема ранов и прочих хэндлов?
Когда называют метод process - назовут класс DoSomeProcessor. Когда называют метод execute - назовут класс DoSomeExecutor. И все в таком духе, по итогу получается «50 оттенков сервисов», в которых сложнее разобраться, особенно при онбординге новым разрабом
Небольшим, но плюсом является, что мы не сталкиваем лбами ценителей одного нейминга с ценителями другого, мы его просто убираем, потенциально экономя человеко-часы.
Делаем код немного короче и лаконичнее, т.к. больше не нужно прописывать название конкретного метода при вызове сервиса.
DoSomeService не лучше. С учетом что сервисы вообще бывают доменные, прикладные и инфрастурктурные - пусть лучше будет процессор. А лучше просто DoSome. Ещё было слово Interactor
Ничего не убрали, мы же спорим прямо сейчас. Нет никакой разницы как назвать, главное чтобы единообразно в проекте.
DoSomeService не лучше. С учетом что сервисы вообще бывают доменные, прикладные и инфрастурктурные - пусть лучше будет процессор. А лучше просто DoSome. Ещё было слово Interactor
Мы пришли к умозаключению (и вы вправе с ним не соглашаться), что если мы говорим о сервисном слое, давайте так же назовем и классы. Некий UL в рамках подхода и кода. Просьбу «поищи такой то код в таком сервисе» легче понять. Ищешь классы которые заканчиваются именно на «…Service», а не на что-то другое. Эту разницу особо остро чувствуют разработчики, которые только знакомятся с проектом.
Ничего не убрали, мы же спорим прямо сейчас. Нет никакой разницы как назвать, главное чтобы единообразно в проекте.
Я не совсем про то, что происходит в комментариях на Хабре. Кто-то спорит, что Земля плоская, и это их право. Я скорее про то, что происходит в рамках команды разработчиков, которые принимают те или иные решения и проводят друг другу ревью.
В целом я не буду спорить, что большой разницы нет. Вы правы, что главное - единообразие и вы спокойно можете использовать другой нейминг если в рамках вашей команды он вас больше устраивает.
Единая точка входа ≠ один метод на весь класс.
У Вас одна публичная функция а значит для любого пользователя класса - у Вас только одна функция. То что Вы написали внутри 10-20 приватных для своего удобства это никак не меняет семантики - можете все скомпоновать в одну или разбирать на 1000, для пользователя класса ничего не измениться. Более того, если Вы перепишите класс на обычную функцию которая будет вызывать вспомогательные функции, по большей части ничего не измениться, потому что никакие особые свойства объектов не используете.
Имхо, гораздо легче воспринимать и читать код, когда разработчик позаботился о том, чтобы завернуть его в класс. Наличие класса дает всем методам единый контекст их использования. Кстати именно такую идею продвигают, например, те же самые Enum-ы:
SOLD = 0
FREE = 1
BOOK = 2или же
class ProductStatus(enum.IntEnum):
SOLD = 0
FREE = 1
BOOK = 2во втором случае все три переменные объединены общим контекстом и в этом сила использования класса над никак не связанными фрагментами кода (функциями или чем бы то ни было)
кстати, эту же идею Вы можете обнаружить в книге "Чистый Код" от Роберта Мартина
Имхо, гораздо легче воспринимать и читать код, когда разработчик позаботился о том, чтобы завернуть его в класс.
Гораздо проще читать код, когда в нем нет ничего лишнего, например класса, который используется не по прямому назначению а только для того чтобы в него что-то завернуть.
кстати, эту же идею Вы можете обнаружить в книге "Чистый Код" от Роберта Мартина
Лучше, вместо того чтобы читать старые книги сомнительного содержания, почитать свежий блог автора этой книги. Откроете для себя много нового, например что у него сейчас любимый язык на котором он пишет это Clojure, и что он за минималистичность простоту и про прочее за все хорошего против всего плохого.
Гораздо проще читать код, когда в нем нет ничего лишнего, например класса, который используется не по прямому назначению а только для того чтобы в него что-то завернуть.
Вы удобным себе образом процитировали только часть моего комментария. Не вижу смысла дальше заниматься буквоедством.
у него сейчас любимый язык на котором он пишет это Clojure, и что он за минималистичность простоту и про прочее за все хорошего против всего плохого.
дай Бог здоровьечка
старались, насколько это возможно, не изобретать велосипед
и изобрели... функции
Нет, может в каком-то контексте имеет смысл так необычно организовывать код, но из статьи по моему этот контекст не понятен.
Ещё есть аргумент против использования __call__ - если не ошибаюсь с ним невозможно вызвать goto-definition в месте вызова метода, чтобы перейти к определению __call__
Подскажите, пожалуйста, что вы имеете ввиду когда пишите «функции»? В нашем случае класс может содержать > 1 метода, просто один публичный и по умолчанию это метод __call__. Возможно, из статьи это не очевидно, хотя я приложил примеры с абстрактным классом, где используется шаблонный метод.
По поводу goto-definition - справедливое замечание, спасибо
Нельзя чтобы ваш сервис возвращал:
Словарь/список/кортеж и тем подобные структуры.
Вместо этого следует использовать DTO для возврата структурированных данных:
Вспоминая, что DTO - любая структура данных без логики, которую можно сериалзовать и передать по сети, я не понимаю почему вы считаетете что list[int] или tuple[int, int] - это не DTO. Идея описать выходные данные и дать им структуру - хорошая, но если вы хотите вернуть просто список, почему бы не вернуть его как етсь?
Тут могут возникнуть другие рассуждения в духе "одного списка никогда не хватит, появятся метаданные" и они имеют смысл, но вы же вообще не про это
Когда мы возвращаем кортеж/список/словарь, проблема возникает не в самом сервисе, а за его пределами, когда кто-то пытается взаимодействовать с результатом работы сервиса. Начинают появляться подобные конструкции:
is_success, message = DoSomeThing()()
# или
user_data = GetUserData()(id=id)
name = user_data["name"]
phone = user_data["phone"]Приходится лишний раз напрягаться чтобы вспомнить какие там ключи у словаря.. или сколько же в списке/кортеже элементов чтобы их правильно распаковать и что они вообще значат.
В качестве исключения могу согласиться, если нам не нужно никаким образом распаковывать структуру, например, если сервис вернул список каких-то айдишников и это можно воспринимать как одну логическую единицу.
Писят два миллиона строк... Страшно представить... Это же как два ядра Линукс в сумме. Точно писят два? Не просто "два"?
Ну а по теме скажу так, мы тоже пробовали такой подход и нам не зашло. Из проблем было: проблемы переиспользования сервисов, глубокая вложенность сервисов (когда один вызывает второй, затем третий и в итоге - лапша), большой шанс дублирования и нелепых попыток его избегания, когда связанность проекта растет как на дрожжах.
В общем-то идея хорошая, но только для ситуаций когда такие сервисы сводятся к функциям без состояния (в том числе без работы с БД), вот тогда подход раскрывается просто за счёт того, что тестами такие функции покрыть легко, они не имеют эффектов и фактически это уже ФП.
Мы в итоге остановились на более гибком подходе - Feature slice. Эт когда для каждой фичи есть блок "сервисов", который более гибок чем стандартный шаблон одного класса(с точки зрения организации кода), но при этом сервисы не могут переиспользоваться между фичами. Таким образом high cohesion достигается из той жёлтой книжки из превью к статье. А вот переиспользуемый код уже как раз в функциональном стиле и он пишется в отдельных блоках.
Честно говоря - это не просто. Обилие логики всегда порождает связи, которые потом влияют на развитие, мешают вносить изменения и "ломают то, что мы даже не меняли*. Кажется, это проблема больших систем в целом, а не подхода в частности. Одно из решений - повальное дублирование и жёсткие контракты (формата HTTP API). Но микросервисами такой подход попахивает, а ими мы тоже уже наелись.
Что дальше? Может быть модульный монолит где каждый модуль - отдельная фича? Может быть...
Как уже справедливо заметили, для описанного в статье лучше подходят функции, а не датаклассы.
Однако, бизнес-логика на датаклассах + __call__ все еще имеет смысл - в контексте зависимостей
@dataclass
class JiraExportService:
jira: JiraRepository # зависимость
def __call__(self, issue: Issue) -> None: # аргументы
if self.jira.issue_exists(issue.key):
self.jira.update_description(issue.key, issue.description)
else:
self.jira.create_issue(issue)
Открыл из-за превью, она крутая. А можно все в таком стиле?))
Такой подход однозначно лучше, чем полное отсутствие отделения бизнес логики от других слоёв, но существует мнение, что если мы используем ООП а не процедурное программирование то бизнес логика все же должна находиться внутри объекта с бизнес данными. Концентрация бизнес логики в сервисах считается анти-паттерном "Anemic model". Тут можно почитать об этом подробнее https://martinfowler.com/bliki/AnemicDomainModel.html
Спасибо за комментарий, прочитал статью. Кажется, что в ней автор говорит о доменной логике, а не бизнес-логике, с чем я полностью согласен, что она должна находиться в доменных моделях.
Мое упущение что я забыл включить это в статью, постараюсь дополнить в следующей.
Доменная логика это и есть бизнес логики, разве что есть какая то другая интерпретация (как это часто бывает в IT) о которой я не слышал :)
Доменная логика это и есть бизнес логики, разве что есть какая то другая интерпретация (как это часто бывает в IT) о которой я не слышал :)
Приведу пример чтобы было понятно, что я имею в виду:
Допустим у нас есть доменная модель квартиры, которую пользователь может купить в ипотеку, и эта модель имеет свою доменную логику - она может подсчитать, например, итоговую стоимость в зависимости от способа оплаты - ипотека/рассрочка/кеш. Предположим что каждый способ оплаты имеет свою наценку. И в качестве упрощения я просто сделаю поле "markup".
class Appartment(models.Model):
area = FloatField("полезная площадь")
price = FloatField("цена за квадратный метр")
markup = FloatField("наценка на area * price")
def get_total_price(self) -> float:
return (area * price) * (markup + 1)Также представим что квартира может быть в разных статусах:
class Appartment(models.Model):
FREE, BOOKED, SOLD = range(1, 4)
status = SmallIntegerField("статус (FREE/BOOKED/SOLD)")
area = FloatField("полезная площадь")
price = FloatField("цена за квадратный метр")
markup = FloatField("наценка на area * price")
def get_total_price(self) -> float:
return (area * price) * (markup + 1)
def is_available(self) -> bool:
return self.status == FREEИ наша задача написать сервис который будет бронировать квартиру и вносить первоначальный взнос - это уже бизнес-логика, конкретный бизнес-процесс. Здесь мы можем напридумывать сколько угодно бизнес правил, например, что квартиру нельзя забронировать если первоначальный взнос меньше 10% от ее стоимости.
@dataclass(slots=True, kw_only=True, frozen=True)
class BookAppartmentService:
deposit: int
@log_service_error
def __call__(self, *, appartment: Appartment) -> None:
deposit_in_percent = self._get_deposit_in_percent(appartment=appartment)
if deposit_in_percent < 0.1:
raise DepositNotEnoughError(
deposit=deposit,
appartment_price=appartment.get_total_price(),
)
if appartment.is_available:
appartment.status = BOOK
appartment.save(update_fields=["status"])
def _get_deposit_in_percent(self, *, appartment: Appartment) -> float:
return self.deposit / appartment.get_total_price()
Таким образом получается что сервис по сути один за другим проверяет бизнес-правила. В моем понимании это и есть бизнес-логика. Примеры доменной модели и сервиса дико упрощены, сервис может работать с несколькими доменными моделями при необходимости - в зависимости от бизнес требований. А доменная модель иметь более сложную логику чем то что я написал, но сути дела от этого не изменится.
Почему всю эту логику нельзя поместить в метод Apartment::book()?
В первую очередь потому, что в таком случае мы рискуем очень сильно раздуть доменную модель. По нашему опыту в компании - так и случилось и мы имеем класс почти на 5 тысяч строк. Не буду лукавить и говорить что все, кто притрагивался к этому классу, правильно размещали методы и декомпозировали код должным образом, но даже при правильном подходе там было бы точно больше тысячи строк. А это уже многовато. И по итогу все равно привело бы к раздуванию. Код становится банально сложно читать из-за того что там полотно в одном классе.
Во-вторых, как я уже сказал, сервисный слой может взаимодействовать с несколькими доменными областями, он как бы объединяет несколько "бизнес-поинтов" в один бизнес процесс. Наверно, можно сказать что это некоего рода юз-кейс, но я бы не хотел так его называть.
Эту проблему решают ограниченные контексты https://www.martinfowler.com/bliki/BoundedContext.html
В теории - да, а на практике в живом проекте, где уже все перемешано в кучу - к сожалению нет.
Главная проблема DDD - его тяжело встретить в реальном мире. Ну, может только я такой невезучий и все проекты уже давно перешли на него :=)
Ну и все таки если вы говорите про ограниченные контексты, значит имеете в виду, что логика доменной модели не должна выходить за какой-то ее доменный контекст (который, к слову, определяет сам разработчик).
Представьте если в методе book() нужно будет использование другой доменной модели, которая никак не связана с моделью Appartment. Например, первоначальный % по депозиту хранится в каких-то настройках:
class SiteDefaults(models.Model):
deposit = FloatField("первоначальный взнос для бронированяи на сайте")Теперь уже кажется, что как-будто размещая эту логику в методе book - мы выйдем за пределы контекста? Или все еще нет..? А если вдруг бронирование предполагает взаимодействие с каким-то внешним сервисом, то тут остается два пути:
"Пихать" работу с внешним сервисом внутри метода book
Вызов метода book больше не означает что квартира забронирована должным образом. Мы делаем некий класс который будет работать поверх метода book и делать доп. запрос во внешний сервис (тот самый сервисный слой)
Агрегат из одного контекста может для принятие бизнес решения получать данные из другого, главное чтобы он делал это не напрямую, а через абстракцию, например такую https://habr.com/ru/articles/799019/
Менять состояние другого агрегата он не напрямую тоже не может, тут на помощь приходит event driven архитектура https://martinfowler.com/articles/201701-event-driven.html
Бизнес контексты определят на разработчик а структура бизнес процессов, иногда структура отделов организации, но тут вы правы, научиться их правильно определять не всегда просто, особенно если если это страртап, и структура бизнес процессов еще не до конца понятна.
Да DDD имеет высокий порог входа, и требует высокой квалификации, но из моей практики это самый эффективный способ борьбы со сложной бизнес логикой. К сожалению я не могу показать исходный код коммерческих проектов в которых мы применяем DDD но могу поделиться гайдлайном с описанием правил которым мы следуем в процессе разработки https://gitlab.com/grifix/sandbox/-/blob/main/GUIDELINE.md
Кроме этого одна бизнес сущность это не обязательно один класс. Это может быть агрегат из нескольких сущностей и объектов значений https://martinfowler.com/bliki/DDD_Aggregate.html
Не поймите меня неправильно - я не противник DDD. Проблема не в подходе, а в реалиях с которыми приходится сталкиваться когда заходишь на проект. И DDD почти нигде нельзя нормально применить, потому что это будет очень дорого и бизнес к такому как правило не готов.
В моем случае, мы имеем более простую реализацию, более простую идею и как следствие практически применимую функциональность которая может упростить и структурировать код даже на запущенном проекте.
Как я уже писал выше, я согласен с тем что у DDD высокий порог входа, и его применение не всегда оправдано, и то что вы реализовали на текущем проекта уже даст большой профит в борьбе со сложностью, но если вам интересно залезть в эту кроличью нору чуть глубже, то рекомендую попробовать Rich Model, для начала хотя бы на каком-нибуть пет проекте :)

Сервисы — место, где живет бизнес-логика