Как скомпоновать приложение? Какие в нём должны быть слои? Как назвать пакеты? Где расположить DTO, маперы, реализации интерфейсов? И нужны ли вообще интерфейсы? Когда новичок попадает в свою первую компанию, очень часто на эти вопросы у него нет однозначного ответа. Он смотрит код своих коллег, и тут уж как повезёт - если команда сильная, у новичка есть все шансы научиться писать хороший, чистый, код. Если же не повезёт, то новичок будет цепляться за то, что есть, нахватается плохих практик, и по прошествии года-двух он уже сам будет себе авторитетом, которого не так-то просто будет переубедить.

В посте ниже я описываю личный и командный опыт, под который я постарался подвести теоретическую базу, опираясь на "Чистую архитектуру" Роберта Мартина. Да, этот пост - для новичков, хотя, эта тема жива и среди устоявшихся программистов, поскольку споры о компоновке приложения не утихают и среди сениоров.

Зайду я немного издалека и напомню, что такое луковичная архитектура.

Что такое луковичная архитектура?

В "Чистой архитектуре" Роберта Мартина описывается центрическая архитектура, ядром которой является бизнес-сущность.

Луковичная архитектура
Луковичная архитектура

Бизнес-сущность

Бизнес-сущность ничего не знает ни о логике приложения, ни о слое доступа к данным, ни уж тем более о подключаемых внешних интерфейсах - она содержит только собственные поля и базовые методы для работы с ними - геттеры, сеттеры, контроллеры, билдеры и всё, что необходимо сущности для работы с самой собой.

Внутренняя структура сущности
Внутренняя структура сущности

В практической реализации бизнес-сущность помещается в отдельный пакет верхнего уровня, внутри которого она может быть разбита по функциональным пакетам и содержать пакет type c типами-перечислениями.

Логический слой приложения

Архитектурное ядро окружает слой бизнес-логики. Вся бизнес-логика описывается в слое бизнес-логики и нигде более. Все бизнес-требования реализуются здесь. Логический слой знает всё про сущность, работает с ней и только с ней; но он не знает ничего более - в частности, он ничего не знает и не должен знать про то, с какими подключаемыми интерфейсами работает приложение и каким образом происходит подключение. Он ничего не знает и не должен знать про хранение данных, сетевые протоколы, UI и прочее. Только логика.

Логический слой целиком и полностью зависит от бизнес-сущности, и больше ни от чего не зависит.

Структура сервиса на уровне интерфейса
Структура сервиса на уровне интерфейса
Тогда почему в сервисных классах существует ссылка на репозиторий?

Окей, сервису известна бизнес-сущность и бизнес-логика и ничего более, как в таком случае возможна инъекция репозитория в качестве поля? Ведь таким образом сервису становятся известны компоненты DAO, что недопустимо в луковичной архитектуре.

Объяснению этого феномена посвящён принцип Dependency Inversion (инверсии зависимости). Согласно которому, связи между бинами реализуются через интерфейсы. Поскольку интерфейс является декларацией и не содержит в себе реализации, добавление ссылки на интерфейс репозитория допустим в реализации сервиса, и это не ломает луковичную архитектуру. В интерфейсе же сервиса никаких упоминаний о внешних слоях быть не должно.

Data Access Object

Логический слой окружает слой доступа к данным - Data Access Object. Это репозитории, контроллеры, клиенты. DAO знает о том, с какой базой данных работает приложение, по какому протоколу происходит сетевое взаимодействие, какие дополнительные сервисы требуются для доступа к тем или иным данным. При этом, DAO ничего не знает о работе внешних систем - только о подключении к ним.

Поскольку сервис оперирует данными только при помощи бизнес-сущности, DAO взаимодействует данными с сервисным слоем также при помощи бизнес-сущности.

Верхнеуровневая компоновка приложения

На практике, взаимосвязь между слоями "Сущность - Бизнес-логика - DAO" выглядит так:

Верхнеуровневая компоновка приложения
Верхнеуровневая компоновка приложения

Или так:

Верхнеуровневая компоновка приложения
Верхнеуровневая компоновка приложения

Впрочем, первый вариант более привычен для большинства разработчиков, с которыми мне приходилось работать (кроме Игорька).

Я опустил вспомогательные пакеты первого уровня, как-то: config, util, exception прочие. Но в Вашем приложении они, конечно, будут.

Архитектурная связь слоёв приложения при этом будет такой:

Структура основных слоёв приложения
Структура основных слоёв приложения

Сущность не знает ничего за пределами самой себя. Сервис на уровне интерфейса имеет информацию о сущности, на уровне реализации имеет информацию о наличии репозитория. Контроллер на уровне реализации знает про сервис, на уровне интерфейса знает про подключаемые интерфейсы. Репозиторий на уровне интерфейса знает про сущность, на уровне реализации знает про подключаемые интерфейсы.

Компоненты слоя DAO имеют собственные модели, позволяющие им работать с внешними интерфейсами. Таковыми являются DTO для контроллера и репозитарные сущности для репозитория.

Как компоненты DAO взаимодействуют данными с бизнес-слоем?

В части владения данными, функциональные слои имеют следующие ограничения:

  • Сервис знает только про бизнес-сущность и оперирует только ей.

  • DAO знает про бизнес-сущность и про свои DTO. В части взаимодействия с внешними интерфейсами DAO оперирует DTO. В части взаимодействия с бизнес-слоем DAO оперирует бизнес-сущностью.

Обмен данными в пределах слоя DAO
Обмен данными в пределах слоя DAO

Таким образом, мапинг из бизнес-сущности в DTO и обратно реализуется в DAO.

Где, в итоге, размещать маперы?

Маперы размещаются в подпакетах пакетов DAO. На практике это выглядит так:

Структура пакета DAO
Структура пакета DAO

Пакетов DAO может быть несколько (репозиторий / контроллер / клиент и так далее). В пределах каждого пакета необходимо следовать правилу:

  1. Взаимодействие данными с бизнес-слоем только через бизнес-сущность.

  2. Взаимодействие данными с внешним интерфейсом только через DTO.

  3. Дополнительные пакеты model и maper.

Получается чистая архитектура. Сервис знает только про бизнес-сущность. Обмен данными между сервисом и DAO происходит через бизнес-сущность. DAO взаимодействует с внешним интерфейсом при помощи понятной интерфейсу модели DTO, с сервисом - при помощи понятной сервису бизнес-модели. Мапинг между DTO и бизнес-моделью происходит в пределах слоя DAO. Все при своих.

Таким образом, на практике слои приложения будут выглядеть так:

Типовая структура пакетов
Типовая структура пакетов

Подпакеты impl, model, mapper в каждом пакете слоя DAO и подпакет impl в слое service. Service пользуется напрямую бизнес-сущностью, и, как правило, собственные модели ему не нужны - а значит, и маперы тоже.

Почему во многих командах допускается отсутствие интерфейсов для контроллеров?

Действительно, многие команды игнорируют интерфейсы для контроллеров и пишут сразу реализацию. И для этого есть причины.

Интерфейс, как мы знаем, выполняет две основные функции:

  1. Служит контрактом компонента, в котором декларируется его поведение.

  2. Используется в качестве стабильных узлов для реализации зависимостей между компонентами приложения.

Поскольку контроллер сам является точкой входа и в качестве стабильного узла для связи компонента не используется, разработчики часто игнорируют первое правило и не пишут интерфейсы для контроллеров вообще.

Насколько это правильно или нет, вы можете определить в рамках команды самостоятельно.

Архитектура же приложения будет следующая:

Полная структура приложения с маперами
Полная структура приложения с маперами

Заключение

Все мы видели немало проектов, в которых нет чёткого разделения данных. Самым популярным нарушением луковичной архитектуры в современной практике является мапинг Hibernate.

Типичный ORM.
Типичный ORM.

В случае использования Hibernate бизнес-сущность знает почти всё о строении базы данных и работе DAO. В соответствии с луковичной архитектурой, следовало бы завести специальные репозиторные сущности в слое repository и реализовать мапинг с бизнес-сущностью, но я ни разу не видел, чтобы разработчики, которые взяли Hibernate, нашли в себе силы это сделать :)

Если для приложения поменяется способ хранения данных (например, на Mongo), такое приложение переписать будет практически невозможно, поскольку Hibernate пронизывает всё приложение до бизнес-сущности. Если же архитектура луковичная, цена таких изменений несоизмеримо ниже.

DTO залезают в бизнес-слой (я видел такое неоднократно). DTO вообще отсутствуют и на клиент уходит бизнес-сущность (и такое я тоже неоднократно видел). Мапинг прямо в сервисе. Мапинг в бизнес-сущности. Всё это, и многое другое, создаёт лишние и очень дорогие связи между слоями. Да, вы напишете это приложение и доведёте его до релиза, но впоследствии цена доработок будет расти в геометрической прогрессии, и это не мои слова, это слова Роберта Мартина.

Изображение из книги Роберта Мартина "Чистая архитектура"
Изображение из книги Роберта Мартина "Чистая архитектура"

И если Вы найдёте время почитать "Чистую архитектуру" Роберта Мартина (в России эту книгу выпускает издательство "Питер") - найдите и почитайте. Ну а пока вы этого не сделали - соблюдайте принципы чистой архитектуры в представлении архитектуры луковичной.

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


  1. KirovA
    22.11.2022 07:24
    +6

    Ну а пока вы этого не сделали - соблюдайте принципы чистой архитектуры в представлении архитектуры луковичной.

    - А вы соблюдаете заповеди?

    - Вы подобрали нас, верно? Придётся.

    - Приятно слышать. Но куда проще сказать, что соблюдаешь заповеди, чем правда соблюдать.(C)

    Это я к тому, что все это красиво и просто. В теории. На практике, - много где видел, что задумывалось хорошо, но бизнесу надо вчера, на рефракторинг времени нет, а писать тесты дорого. Зато, на содержание кучи аналитиков и постоянные созвоны - время и деньги есть. Мне даже иногда кажется, что разработка на некоторых проектах вещь второстепенная, - главное - побольше встреч аналитиков, побольше правок в Конфлюэнс, потом, когда разработчики эти правки увидят, нужны будут еще встречи с разработчиками, правки правок и так до бесконечности.


  1. Bakuard
    22.11.2022 08:15
    +7

    Бизнес-сущность ничего не знает ни о логике приложения

    И при этом вы ссылаетесь на чистый код Роберта Мартина.

    Описанный Вами подход называется Simple Domain Model. Он широко распространен и, безусловно, имеет право на жизнь. Однако имеет и ярых противников, одним из которых как раз и является Роберт Мартин. В упомянутой Вами книге он называет этот подход "анемичной моделью" и подвергает резкой критике. Мне кажется, было бы не плохо, если бы в статье вы указали, где отходите от подхода описанного в "чистом коде".


    1. michael_v89
      23.11.2022 13:42

      Лично я пришел к выводу, что бизнес-логика внутри сущности это практически неподдерживаемый подход. Надо отправить email после выполнения действия, вы его из сущности будете отправлять?


  1. TatagulovOlzhas
    22.11.2022 13:00
    -2

    Работал я на проекте в котором была похожая архитектура и мы тем только и занимались что писали мапперы из DTO в бизнес сущьности, а потом в entity. В итоге на это уходило кучу времени. К этому еще и тесты нужно написать, которые не всегда спасали. Добавли новое поле, но забыли добавиьт в маппер, тесты все равно будут зелеными.

    Так же вы не сможете всю логику выстроить в бизнес слое. Например вам нужно брать данные по какому то фильтру, вы же не будете тянуть всю таблицу из базы (а если там связанные даные, еще хуже) и в сервис слое делать фильтрацию. Вы начнете строить условия уже на DAO слое и вот ваша бизнес логика разделилась.

    Переход на другую базу это тоже миф. В любом случае вы будете использоваь спецефичные для базы вещи, и их придется переделывать. Например JPQL или Native Query которые не дадут вам просто преехать на монгу.

    Интерфесы не нужны, зачем вам они? у вас одна реализация. Если нужны тесты используте Mock.

    я на своих проектах использую JOOQ и маплю результаты запроса сразу в DTO без всяких пробмежуточных объектов. Это намного проще и удобнее. Если операция совсем простая, то можно из контролера сразу в DAO слой обращаться.


  1. michael_v89
    23.11.2022 13:36

    За время работы пришел к такой архитектуре. Не знаю, насколько она луковичная, но она удобная в поддержке и простая для понимания.


    — Контроллер это точка входа. В одном классе-контроллере могут быть несколько public-методов. Это позволяет соблюдать принцип high cohesion и переиспользовать private-методы — поиск сущности по id из запроса или проверка доступа.


    — В контроллер пробрасывается объект сервиса с бизнес-логикой. Бизнес-логика находится в сервисах, а не в сущностях. Список методов сервиса соответствует списку методов класса-контроллера. Один метод класса-контроллера это точка входа для вызова соответствующего метода сервиса. Другой контроллер для работы с той же сущностью подразумевает другой сервис, если нельзя свести к вызовам существующего. Например, для админки сущности нужен другой сервис с действиями CRUD, не тот, который используется для пользовательской части. У пользователя, как правило, нет возможности произвольно изменять любые поля.


    — В контроллере производится валидация, в результате которой создается DTO с валидированными данными, либо возвращается ответ, что валидация не прошла, со списком ошибок для пользователя. Для сложных бизнес-проверок можно сделать метод в сервисе или отдельный сервис.


    — DTO, соответствующее правилам валидации, передается в метод сервиса. Это позволяет в реализации бизнес-логики полагаться на то, что данные валидны, и в случае ошибок просто бросать исключение с записью в лог. Сообщения пользователю должны отправляться только на этапе валидации. Из этого правила есть исключение, когда в реализации бизнес-логики нужно вызвать сторонний сервис и вернуть пользователю его результат. Для этого можно использовать агрегирующий класс наподобие монады Either, объект которого возвращается из сервиса.


    — С таким подходом типизированные исключения в реализации бизнес-логики становятся не нужны. Исключения должны сообщать об исключительной ситуации и носить технический характер. Например "ServiceUnavailableException", "NullPointerException", но не "ValueLessThanZeroValidationException".


    — Из контроллера в метод сервиса передается сущность, с которой нужно сделать бизнес-действие. Внутри сервиса сущность по id не подгружается, так как от отсутствия сущности зависит ответ контроллера.


    — Идеальный поток выполнения, это когда любая сущность за время запроса загружается один раз (хаки c кэшем ORM не считаются). Один и тот же runtime-объект передается и в слой контроля доступа, и в слой валидации, и в слой бизнес-логики. Это же относится к валидации — если для валидации понадобилось подгрузить какую-то связанную сущность, не нужно ее еще раз подгружать в сервисе с бизнес-логикой. Это означает, что в процессе валидации появляются артефакты, которые можно использовать далее, и это нормально. Без преобразований входных данных нельзя провалидировать даже простой int, пришедший в виде строки из HTML-формы. А если мы уже сконвертировали string в int, нет смысла это выбрасывать и конвертировать снова в бизнес-логике.


    — Автоподгрузка связей в сущности при обращении к ним часто приводит к проблеме N+1. Желательно ее вообще отключить, указывать нужные связи в запросе ORM или подгружать их явно через соответствующий репозиторий.


  1. michael_v89
    24.11.2022 15:01

    Логический слой целиком и полностью зависит от бизнес-сущности, и больше ни от чего не зависит.
    На уровне интерфейса репозиторий выглядит так же, как сервис.
    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), например если нам надо увеличить счетчик просмотров.