Вводная вода
Как бы мы ни стремились к идеальным чистым архитектурам, разделенным по слоям или хотя бы с отделенным ядром приложения (application core), достигнуть идеальности невозможно. Вот вообще никак. Абсолютно. В процессе разработки необходимо принять как данность наличие множества компромиссов и попытаться найти баланс между чистотой кода и разумной тратой времени и ресурсов на разработку.
Так получилось, что я пощупал несколько популярных DataMapper ORM-библиотек на совершенно разных языках: EntityFramework для .NET, Hibernate для JPA, Doctrine для PHP и бонусом посмотрел менее популярный CycleORM для того же PHP. Все они построены по примерно одинаковым подходам с использованием DataMapper и Unit of Work и, единожды вникнув в любой из них, не составляет никакого труда разобраться в подобных других. Да, само собой, некие различия у них есть, их не может не быть. Однако общая концепция, без углубления в подкапотные детали, практически идентична.
Когда мы говорим об ядре приложения, мы подразумеваем, что есть некая отделенная реализация бизнес-процессов моделируемой предметной области и все остальное вокруг, что связывает ядро приложение с внешним миром. Цель такого отделения логична и довольно проста: мы не хотим, чтобы наш основной код, выполняющий бизнес-логику и содержащий бизнес-сущности, был завязан на выбранных технологиях, хранилищах и протоколах обмена данными.
В интернете можно встретить распространенное мнение, что такое отделение позволяет менять фреймворки как перчатки или подменять одну технологию другой. Отчасти, конечно же, в этом есть какой-то смысл, однако на практике мы не меняем применяемые технологии по первой прихоти. Технологии в целом выбираются более осознанно, чтобы учитывать как текущие реалии, так и некоторые будущие перспективы. Кроме того, есть и такие технологии, смена которых затрагивает большой, если не сказать огромный, кусок приложения, а значит менять технологию здесь и сейчас может быть в целом нецелесообразно и дорого. Как часто мы меняем один фреймворк на другой в существующем действующем приложении, находящемся в продакшене? Примерно никогда.
Глобальное переписывание целого приложения на новые технологии всегда подразумевает создание нового приложения рядом и какую-то выбранную стратегию для миграции со старого на новое. И, как будто бы, создавать ядро приложение обособленным не имеет никакого смысла.
Однако, по личному опыту, более реальная ситуация возникает на миграции на более новую версию фреймворка, особенно, когда осуществляется переход на новую мажорную версию по semver, что часто приносит ряд требующих доработок несовместимостей. В этом случае возникает желание стремиться к отделению бизнес-логики от инфраструктурных зависимостей так, чтобы сократить количество мест, которые повлекут за собой доработки после обновления пакетных зависимостей.
К сути
Ядро приложения включает в себя некоторый набор бизнес-сущностей с определенным, отражающим модель бизнеса, поведением. Бизнес-сущности в своем подавляющем большинстве предполагают также и необходимость хранения данных в каком-либо хранилище. Это значит, что у нас появляется две фундаментальные задачи, которые обязаны быть решены в нашем приложении:
- извлечение данных из хранилища и преобразование их в состояние сущности;
- преобразование состояния сущности в данные и помещение их в хранилище;
При использовании DataMapper требуется иметь в каком-либо виде (зависит от используемой ORM) маппинг полей сущностей на поля таблиц или документов в хранилище. От этого никуда не деться, так как приложение должно знать, как перегнать данные из таблиц в поля сущности при чтении и выполнить обратную операцию при записи.
Перечисленные выше ORM, которые я смотрел, предлагают несколько способов такого маппинга:
- через метапрограммирование, когда у полей сущностей указываются аннотации или атрибуты с указанием знаний о полях таблицы;
- через отдельный конфигурационный маппинг, выглядящий или как отдельный конфигурационный файл в каком-то формате, или как конфигурационный код, где маппинг прописывается в виде схемы прямо в коде.
Атрибуты или аннотации
Первый способ с использованием аннотаций или атрибутов внедряет в нашу доменную сущность несвойственные домену инфраструктурные знания, которые в идеальном ядре приложения вообще не должно быть. Кроме того, практически всегда метапрограммирование идет рука об руку с аспектно-ориентированным программированием, что дает нам поверх доменных сущностей еще и некоторое неявное поведение, отвечающее далеко не за бизнес-процессы, например pre-persist хуки.
@Entity
@Table(name = "`user`")
class User(
@Id
@Column(name = "id", nullable = false)
@Type(UserIdType::class)
val id: UserId,
) {
@Column(name = "status", nullable = false)
@Convert(converter = UserStatusConverter::class)
var status: UserStatus = UserStatus.ACTIVE
@Column(name = "created_at", nullable = false)
val createdAt: LocalDateTime = LocalDateTime.now()
}
Зато этот способ приносит один полезный плюс: наш маппинг находится недалеко, прямо у полей. Можно все прочесть, не отходя, так сказать, от кассы.
Отделенный маппинг
Второй способ позволяет отделить инфраструктурные знания о хранилище в отдельное место, не примешивая его к домену, а значит с точки зрения чистоты ядра приложения этот способ предпочтительнее. Однако на практике же такой подход требует дополнительных ресурсных затрат.
Во-первых, любая такая конфигурация как правило не представляет из себя пример лаконичности. Обычно такие маппинги довольно громоздки, а с увеличением размера кодовой базы также и увеличивается цена поддержки.
Во-вторых, любая конфигурация требует дополнительных знаний API ORM. Запись через аннотации или атрибуты, безусловно, тоже требует изучения возможностей, и чаще всего представляет из себя небольшие, более довольно понятные и просто выглядящие конструкции. В случае же с отделенным маппингом требуется изучить больше дополнительных спецификаций и муштровать документацию на предмет различных нюансов. Хотя в IDE обычно доступна поддержка автокомплита прямиком в IDE, поэтому фактически оба способа маппинга в этом моменте по трудозатратам идентичны.
В-третьих, отделение конфигурации чаще всего заложено как некая возможность, которой можно воспользоваться, но внедрение таких отделенных маппингов требует дополнительных знаний и телодвижений. Часто фреймворк уже из коробки имеет все необходимые инструменты для автоматического связывания маппинга при использовании аннотаций или атрибутов. В случае же с отделенной конфигурацией, каждый маппинг требуется подключать отдельно в приложении и искать способы сделать это менее рутинным.
В-четвертых, отделение маппинга в отдельные файлы может требовать для себя особые создание и регистрацию тайпкастов. Аннотация или атрибут обычно представляют из себя сахарок для более быстрой интеграции, выполняющий под капотом множество типовых действий, которые обычно скрыты. Отдельный маппинг может потребовать написания еще даже большего количества кода.
<doctrine-mapping
xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd"
>
<entity name="App\Model\Product\Product\Entity\Product">
<id name="id" type="product_id"/>
<field name="type" length="30" nullable="true"/>
<field name="name" nullable="true" length="255"/>
<embedded
name="price"
class="App\Model\Product\Product\Entity\ProductPrice"
column-prefix="price_"
/>
<field name="createdAt" type="datetime_immutable"/>
<field name="updatedAt" type="datetime_immutable"/>
<field name="importedAt" type="datetime_immutable"/>
</entity>
</doctrine-mapping>
Зато маппинг отделен от доменной сущности, что является как плюсом, так и минусом. Доменная сущность стала чище, но маппинг теперь находится в другом месте в отрыве от полей сущности. Любые доработки требуют изменения уже двух мест.
Лично я предпочитаю именно этот способ, так как для меня визуальная чистота доменной сущности более предпочтительна, а затраченное время как правило не превышает условные допустимые нормы. Я не хочу видеть относящихся к таблицам большие портянки из атрибутов или аннотаций у полей сущности.
А есть и третий способ
Итого, мы имеем два подхода: или мы делаем более грязной доменную сущность, примешивая внутрь инфраструктуру, или стремимся отделить инфраструктуру от доменной сущности ценой бОльших телодвижений. Какой выбирать — остается на совести команды разработки и коллективного выбора.
На самом деле, способов не два. Как минимум, существует еще и третий. Никто не запрещает оставить доменную сущность чистой без инфраструктурных вкраплений, а к ней добавить отдельную ORM-сущность, где маппинг основан на лаконичных атрибутах или аннотациях.
И как будто бы вот оно! Тот самый способ, который нам нужен. Тот самый способ, который одновременно убивает двух зайцев, но на практике реализация этого способа потребляет еще больше как временных, так и человеческих ресурсов и сильно увеличивает цену поддержки кодовой базы.
Во-первых, мы вводим не одну сущность, а две. Любые изменения ее требуют изменения в двух местах. Да, сущности в нормально спроектированных приложениях редко подвергаются изменениям. Тем не менее, от изменений никто не застрахован: две зеркальных сущности требуют два изменения, а не одно.
Во-вторых, пока мы имеем простую сущность, реализовать логику переконвертирования из ORM-сущности в доменную сущность и обратно реализовать нетрудно. Стоит учитывать, что эту логику гидрации придется писать руками в любом случае. Однако, все усложняется, когда в сущности появляется one-to-one связь или one-to-many связь с коллекцией. Это подразумевает довольно большие доработки логики гидрирования, которая в ряде случаев даже может быть неоптимизированной и генерировать лишние запросы, которые из-за механизма lazy/eager загрузки ORM-библиотеки могли бы быть нивелированы.
При реализации этого способа стоимость разработки очень сильно возрастает. Как бы хорошо этот способ ни выглядел, разработка через него быстро превратится в ад и принесет за собой неявные проблемы, с которыми придется бороться в будущем в продакшен-контуре.
Проблема связей
Проблема с маппингом вовсе не единственная. ORM-библиотека как правило сопровождается дополнительной функциональностью, позволяющей генерировать инкрементальные миграции на основе различий между схемой базы данных и маппингом ORM-сущности. Это очень полезный инструмент, позволяющий сэкономить тонну времени, не заморачиваясь над написанием миграций вручную.
Такие генераторы требуют, чтобы маппинг ORM-сущности был описан наиболее полно. И тут мы приходим ко второй проблеме DataMapper-библиотек — связи сущностей друг с другом.
Подход с агрегатами, используемый в предметно-ориентированном проектировании, предполагает, что каждый из агрегатов осознанно имеет границы, которые строго-настрого нельзя пересекать. ORM-библиотеки хоть и не предполагают в обязательном порядке объектно-ориентированные связи, тем не менее, генератор миграций напрямую зависит именно от них. Да, мы можем определять поля для связей внутри сущностей без ссылок на другие сущности, используя простые ValueObject ID для полей, но при этом теряется возможность генерации правильных миграций. Генератор не знает ни об одном внешнем ключе и стремится удалить их, если находит.
Мы приходим к двум разным вариантам реализации связей, каждый из них имеет свои плюсы и минусы.
Первый заключается в том, что мы делаем доменную сущность, относящуюся к одному агрегату, без возможности иметь ссылку на другой агрегат. Это позволяет блокировать внутри сущности саму возможность неконтролируемых изменений других агрегатов, что несомненно плюс. Однако, мы теряем возможность пользоваться автогенератором миграций в полной мере.
Второй заключается в обратном: мы используем объектно-ориентированные связь так, как это предполагается самой концепцией ORM-библиотеки, из-за чего получаем возможность генерировать полные миграции одной командой. Но возрастает когнитивная нагрузка из-за контроля межагрегатных взаимодействий, так как по предметно-ориентированному проектированию один агрегат изменять другой не вправе в явном виде.
Опять же, по личному опыту автогенерация миграций оказывается более существенным плюсом, чем агрегатный контроль, поэтому приходится использовать объектно-ориентированные связи. Кроме того, соблюдать границы агрегатов нужно в любом случае все равно и от этого никуда не деться.
Кроме того, использование объектно-ориентированных связей также позволяет Unit of Work разрешать последовательности вставок и модификаций в рамках одной атомарной транзакции, что порой бывает довольно трудно сделать в сложных синхронных обработках для обеспечения транзакционности.
Из двух зол выбираем меньшее.
Подытожив
Не устаю утверждать, что разработка программного обеспечения — это всегда поиск каких-то компромиссов и баланс между различными факторами. Время небесконечно. Ресурсы небесконечны. Деньги небесконечны. Следует всегда анализировать множество различных подходов и выбирать необходимый под конкретную задачу, учитывая также цикл жизни приложения и его поддержки.
DataMapper ORM-библиотеки хоть и стремятся отделить хранение данных от бизнес-сущностей, но код, который мы получаем в итоге, не может быть идеальным и чистым. Так или иначе инфраструктура будет попадаться то тут, то там в каком-либо виде. Важно научиться на некоторые вещи закрывать глаза и уметь осознанно и аргументированно рассказать как концепцию идеального мира, так и осознанную вынужденную принятую концепцию в приложении.
Разработка приложений — это всегда боль. Важно эту боль уметь уменьшать. А на этом у меня все, пойду напьюсь.