Comments 17
Ну а пока вы этого не сделали - соблюдайте принципы чистой архитектуры в представлении архитектуры луковичной.
- А вы соблюдаете заповеди?
- Вы подобрали нас, верно? Придётся.
- Приятно слышать. Но куда проще сказать, что соблюдаешь заповеди, чем правда соблюдать.(C)
Это я к тому, что все это красиво и просто. В теории. На практике, - много где видел, что задумывалось хорошо, но бизнесу надо вчера, на рефракторинг времени нет, а писать тесты дорого. Зато, на содержание кучи аналитиков и постоянные созвоны - время и деньги есть. Мне даже иногда кажется, что разработка на некоторых проектах вещь второстепенная, - главное - побольше встреч аналитиков, побольше правок в Конфлюэнс, потом, когда разработчики эти правки увидят, нужны будут еще встречи с разработчиками, правки правок и так до бесконечности.
Бизнес-сущность ничего не знает ни о логике приложения
И при этом вы ссылаетесь на чистый код Роберта Мартина.
Описанный Вами подход называется Simple Domain Model. Он широко распространен и, безусловно, имеет право на жизнь. Однако имеет и ярых противников, одним из которых как раз и является Роберт Мартин. В упомянутой Вами книге он называет этот подход "анемичной моделью" и подвергает резкой критике. Мне кажется, было бы не плохо, если бы в статье вы указали, где отходите от подхода описанного в "чистом коде".
Лично я пришел к выводу, что бизнес-логика внутри сущности это практически неподдерживаемый подход. Надо отправить email после выполнения действия, вы его из сущности будете отправлять?
Отправить из сущности (передав callable в метод user->sendEmail() - это полнота модели. Но такой полнотой нарушается чистота модели. В зависимости от разных кейсов следует двигаться весы в пользу чистоты или полноты. Все трейдоф.
Если писать логику в сервисах, не нужны никакие трейдоффы. Модель остается чистой и анемичной. Информация, что у модели есть свойство some_value: int
это не деталь реализации, деталь реализации это если вы его храните как some_value: byte[4]
, или как internal_data[{6,7,8,9}]
. Поэтому установка some_value: int
снаружи, тем более через сеттер, это не раскрытие деталей реализации. Анемичная модель более точно моделирует предметную область, в реальности заявка сама себя не заполняет.
Работал я на проекте в котором была похожая архитектура и мы тем только и занимались что писали мапперы из DTO в бизнес сущьности, а потом в entity. В итоге на это уходило кучу времени. К этому еще и тесты нужно написать, которые не всегда спасали. Добавли новое поле, но забыли добавиьт в маппер, тесты все равно будут зелеными.
Так же вы не сможете всю логику выстроить в бизнес слое. Например вам нужно брать данные по какому то фильтру, вы же не будете тянуть всю таблицу из базы (а если там связанные даные, еще хуже) и в сервис слое делать фильтрацию. Вы начнете строить условия уже на DAO слое и вот ваша бизнес логика разделилась.
Переход на другую базу это тоже миф. В любом случае вы будете использоваь спецефичные для базы вещи, и их придется переделывать. Например JPQL или Native Query которые не дадут вам просто преехать на монгу.
Интерфесы не нужны, зачем вам они? у вас одна реализация. Если нужны тесты используте Mock.
я на своих проектах использую JOOQ и маплю результаты запроса сразу в DTO без всяких пробмежуточных объектов. Это намного проще и удобнее. Если операция совсем простая, то можно из контролера сразу в DAO слой обращаться.
За время работы пришел к такой архитектуре. Не знаю, насколько она луковичная, но она удобная в поддержке и простая для понимания.
— Контроллер это точка входа. В одном классе-контроллере могут быть несколько public-методов. Это позволяет соблюдать принцип high cohesion и переиспользовать private-методы — поиск сущности по id из запроса или проверка доступа.
— В контроллер пробрасывается объект сервиса с бизнес-логикой. Бизнес-логика находится в сервисах, а не в сущностях. Список методов сервиса соответствует списку методов класса-контроллера. Один метод класса-контроллера это точка входа для вызова соответствующего метода сервиса. Другой контроллер для работы с той же сущностью подразумевает другой сервис, если нельзя свести к вызовам существующего. Например, для админки сущности нужен другой сервис с действиями CRUD, не тот, который используется для пользовательской части. У пользователя, как правило, нет возможности произвольно изменять любые поля.
— В контроллере производится валидация, в результате которой создается DTO с валидированными данными, либо возвращается ответ, что валидация не прошла, со списком ошибок для пользователя. Для сложных бизнес-проверок можно сделать метод в сервисе или отдельный сервис.
— DTO, соответствующее правилам валидации, передается в метод сервиса. Это позволяет в реализации бизнес-логики полагаться на то, что данные валидны, и в случае ошибок просто бросать исключение с записью в лог. Сообщения пользователю должны отправляться только на этапе валидации. Из этого правила есть исключение, когда в реализации бизнес-логики нужно вызвать сторонний сервис и вернуть пользователю его результат. Для этого можно использовать агрегирующий класс наподобие монады Either, объект которого возвращается из сервиса.
— С таким подходом типизированные исключения в реализации бизнес-логики становятся не нужны. Исключения должны сообщать об исключительной ситуации и носить технический характер. Например "ServiceUnavailableException", "NullPointerException", но не "ValueLessThanZeroValidationException".
— Из контроллера в метод сервиса передается сущность, с которой нужно сделать бизнес-действие. Внутри сервиса сущность по id не подгружается, так как от отсутствия сущности зависит ответ контроллера.
— Идеальный поток выполнения, это когда любая сущность за время запроса загружается один раз (хаки c кэшем ORM не считаются). Один и тот же runtime-объект передается и в слой контроля доступа, и в слой валидации, и в слой бизнес-логики. Это же относится к валидации — если для валидации понадобилось подгрузить какую-то связанную сущность, не нужно ее еще раз подгружать в сервисе с бизнес-логикой. Это означает, что в процессе валидации появляются артефакты, которые можно использовать далее, и это нормально. Без преобразований входных данных нельзя провалидировать даже простой int, пришедший в виде строки из HTML-формы. А если мы уже сконвертировали string в int, нет смысла это выбрасывать и конвертировать снова в бизнес-логике.
— Автоподгрузка связей в сущности при обращении к ним часто приводит к проблеме N+1. Желательно ее вообще отключить, указывать нужные связи в запросе ORM или подгружать их явно через соответствующий репозиторий.
Логический слой целиком и полностью зависит от бизнес-сущности, и больше ни от чего не зависит.
На уровне интерфейса репозиторий выглядит так же, как сервис.
interface UserService { fun save(user: User); fun update(user: User); fun get(id: UUID); fun delete(id: UUID); }
Раз есть дублирование, то это выглядит как неправильный подход. Сервис с бизнес-логикой должен работать с сущностью, а не искать ее по id. Чтобы удалить сущность по id, надо сначала в контроллере ее подгрузить и проверить что она есть, а если нет, то выдать 404. Также может потребоваться валидация дополнительных полей, например сущность нельзя удалять, если она находится в некотором статусе. А раз уже подгрузили, то ее можно передавать в сервис.
Загрузка по id это ответственность репозитория. Репозиторий это абстракция над массивом сущностей, получение по id это аналог получения элемента массива по индексу. В сервисе может быть метод view(User $user)
, например если нам надо увеличить счетчик просмотров.
подгрузить и проверить что она есть
Это уже бизнес-логика. Контроллер является лишь адаптером к внешнему интерфейсу. Вы сами решаете, нужно Вам выбрасывать исключение при отсутствии ресурса. Не контроллер.
Контроллер не выбрасывает исключение, он возвращает HTTP-ответ. Возврат HTTP-кода 404 при отсутствии ресурса это ответственность контроллера. Возврат 400 про ошибках валидации тоже. Поэтому HTTP-контроллер должен уметь реагировать на отсутствие ресурса. А GraphQL-контроллер будет реагировать по-другому, в GraphQL не используются коды 404 и 400.
Вы правы, контроллер не выбрасывает исключение. Исключение выбрасывает сервис.
Для каждого архитектурного решения исключение будет обрабатываться по-своему. Для REST исключение обрабатывается в ExceptionHandler, который и выбрасывает любой необходимый статус. Для GraphQL будет свой обработчик. Но исключение как логический ответ будет одним для всех архитектурных решений.
Отсутствие ресурса по id, который пришел снаружи, это не исключительная ситуация. Как и неправильный ввод данных. Исключительная ситуация это когда база данных должна работать, а она не работает. Или когда ссылка на объект должна быть не null, а она null. Код ожидает одно поведение, а происходит другое. А ожидать, что id снаружи всегда будут приходить правильные, это нелогично.
Возврат правильного HTTP-кода это основное назначение контроллера, поэтому проверка должна быть там, а не в сервисе. В сервис с логикой должна приходить уже сущность и провалидированные данные. Исключения из сервиса тут вообще нигде не нужны, все можно сделать без них с линейными if. К тому же на уровне контроллера это легко автоматизируется, чтобы не надо было это копипастить в каждом экшене, а с сервисом так не получится.
Для REST исключение обрабатывается в ExceptionHandler
А теперь нам надо вызвать ту же логику в цикле в консольной задаче по расписанию. Будем лазить по всему коду искать, какие исключения могут вылететь, чтобы их поймать и обработать как нужно? А потом кто-нибудь добавит для REST третье исключение, а про консольную задачу не подумает, и она упадет на проде. Куча исключений, куча обработчиков для них, нелинейный запутанный код, в чем тут преимущество?
Dto mapping в dao? А если добавятся ещё варианты dto? Скажем свои dto для вебсокет api или специальные dto для public api? Слой Dao получается должен знать все варианты dto и уметь их конвертировать?
Луковичная архитектура в компоновке backend-приложения и куда в итоге класть маперы