Луковичная архитектура в компоновке backend-приложения и куда в итоге класть маперы

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Data Access Object

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

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

6e84fba5a03db0cc7bc38f9e51400145.png

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

На практике, взаимосвязь между слоями «Сущность — Бизнес-логика — 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 вообще отсутствуют и на клиент уходит бизнес-сущность (и такое я тоже неоднократно видел). Мапинг прямо в сервисе. Мапинг в бизнес-сущности. Всё это, и многое другое, создаёт лишние и очень дорогие связи между слоями. Да, вы напишете это приложение и доведёте его до релиза, но впоследствии цена доработок будет расти в геометрической прогрессии, и это не мои слова, это слова Роберта Мартина.

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

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

© Habrahabr.ru