Эргономичная архитектура (v3.0.0)

Эргономичная архитектура (v3.0.0)

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

Предыдущие черновики и диаграммы здесь.

Введение

(todo: ссылка)

В своей книге The Problem with Software: Why Smart Engineers Write Bad Code Адам Барр пишет, что вопреки тому, что деятельность по разработке ПО часто называют "software engineering", а людей, которые разрабатывают ПО - "software engineer", на самом деле эта деятельность инженерной не является.

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

Например, глядя на Чистую архитектуру, можно предположить, что у Роберта Мартина был негативный опыт, вызванный добавлением новых инфраструктурных компонентов в систему. И ответом на этот опыт стал принцип инверсии зависимостей в частности и Чистая архитектура в общем.

А мой негативный опыт был связан с отсутствием структуры в кодовой базе. Ответом на мой опыт стала Эргономичная архитектура.

И следствием этого является то, что в отличие от большинства других распространённых архитектур, Эргономичная архитектура описывается (определяется?) не одной картинкой и набором правил, а тремя картинками и наборами правил, которые я называю проекциями. Это позволяет Эргономичной архитектуре регламентировать структуру кодовой базы более глубоко и всесторонне.

Этими проекциями являются:

  1. Проекция структуры данных - описывает то, какой информацией оперирует система и как связаны кусочки этой информации

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

  3. Проекция структуры поведения - описывает то, как декомпозирована реализация операций системы и как части реализации связаны между собой.

Каждая из проекций определяет:

  1. номенклатуру концептуальных базовых блоков;

  2. ограничения на допустимые связи между ними;

  3. нотацию описания структуры;

  4. ограничения на реализацию этих блоков в коде;

  5. паттерны и примеры структур.

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

На данный момент, ЭА применима только к отдельным сервисам бакэндов информационных систем. Полагаю, её можно адаптировать к более широкому классу программ, но пока что в фокусе только этот класс.

Проекция структуры данных (модель данных)

Проекция структуры данных по большому счёту является комбинацией проверенных идей из DDD, Функциональной архитектуры и Entity-Relation-модели с небольшими отличиями.

Номенклатура блоков

Концептуальными базовыми блоками этой проекции являются:

  1. Сущности - объекты обладающие идентичностью;

  2. Объекты-значения - доменно-специфичные составные объекты, не обладающие идентичностью;

  3. Агрегаты - кластер сущностей и объектов-значений, жёстко объединённых общим жизненным циклом.

Номинально, номенклатура блоков полностью совпадает с DDD. Однако есть ряд отличий:

  1. В ЭА допустимо ссылаться на сущности, не являющиеся корнем агрегата;

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

Связи между блоками

У связи между блоками есть два атрибута: сила (слабая или сильная) и кардинальность (один, немного, много).

Сила связи это аналог связей внутри и между агрегатами в DDD и связей между обычными и стержневыми и слабыми сущностями в ER-модели.

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

ЭА разделяет связи 1-к-немногим и 1-ко-многим. Связь один к немногим - это частый случай связи 1-ко-многим про который вы на этапе проектирования можете сказать, что в этой связи связи будет участвовать определённое, ограниченное и небольше количество сущностей.

Связей многие-ко-многим в ЭА нет. Если два класса/типа агрегата могут быть связаны отношением многие-ко-многим, то в модели они отображаются одним из двух вариантов:

  1. Если на самом деле отношение является многие-к-немногим, то в агрегат на стороне "многие" добавляется объект-значение с невладеющей связью с другим агрегатом, который связан отношениме немногие-к-одному с одной из сущностей или объектов-значения агрегата;

  2. В противном случае для отношения заводится самостоятельный агрегат с двумя невладеющими связями с основными агрегатами (аля связующая таблица в реляционной модели)

Ограничения на связи между блоками:

  1. Сущность не может быть "владеемой" несколькими другими сущностями (читай: сущность может входить только в один агрегат);

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

При следовании этим правилам в графе структуры данных образуется три слоя:

  1. слой генеричных/универсальных агрегатов;

  2. слой ключевых сущностей системы;

  3. слой вспомогательных сущностей системы.

Кодирование блоков

В код попадают только сущности и объекты-значения. И те и другие кодируются в виде неизменяемых классов и различаются наличием или отсутствием поля-идентификатора.

Здесь и далее в качестве примеров кодирования я использую терминалогию Kotlin. Однако ЭА может быть реализована на любом тюринг-полном языке - хоть Си, хоть Хаскель, разница лишь в том, придётся ли при этом бороться с сами языком. Борьбы с языком не будет, если он мультипарадигменный - поддерживает и императивную, и объектную и функциональную модель программирования. И, на самом деле, большинство современных языков таковыми являются - Java, C#, Python, JavaScript, TypeScript, Swift, Rust, F#, Clojure - только то, про что я сходу могу сказать, что точно подходит.

Владеющие связи кодируются полем со ссылкой на целевой объект. Не владеющие связи кодируются полем с типом-обёрткой вокруг идентификатора целевого агрегата.

Очевидно, связи один-к-одном кодируются скалярным типом, а связи один-к-немногим - типом-коллекцией - списком, множеством или мапой

Примеры/Паттерны

Владеющая связь 1-к-немногим

Это классический пример агрегата - заказ и его позиции, упражнение и его шаги из (Trainer Advisor)

Владеющая связь 1-ко-многим

Встречается очень редко. Сходу придумал только свежий пример из Проекта Р, где у меня есть некие таблицы, у которых есть по порядка 1000 неких строк и большинство операций, включая операцию создания, оперируют всей 1000 строк.

Невладеющая связь многие-к-одному

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

Характеризуется, что в UI обычно отображается разными компонентами и компонент для многих сущностей имеет пагинацию или ленивую загрузку в том или ином виде.

Невладеющая связь один к одному в логическом агрегате

Так же пример, навешивания вспомогательной сущности к ключевой. Например, в TA я сейчас делаю интеграцию с Яндекс.Формами, который нужна настройка в виде емейла терапевта в Яндексе. И хоть и жизненный цикл сущности настроек полностью ограничен ж/ц сущности терапевта, эти данные не нужны в каждом контексте и система благополучно прожила несколько лет без настроек.

Поэтому настройки не добавляются в агрегат терапевта, и выделяются в собственный агрегат, который ссылается на агрегат терапевта.

Это очень важная техника, которая предотвращает появление сущностей на 100+ полей (тру стори из последнего проекта), из которых вам в каждом конкретном контексте (операции) надо от силы с десяток.

Невладеющая связь один к одному между сущностью и дефакто-справочником

Другим примером частым, когда сущность может иметь невладеющую связь 1-к-1 является ссылка на справочник.

В качестве примера из ТА можно взять терапевтические задачи, на которые ссылаются приёмы, программы и записи в журнале.

И те и другие (интуитивно) не входят в логический агрегат терапевтический задачи - мы не хотим, чтобы при удалении ТЗ удалялись все приёмы, программы и записи журнала.

Невладеющая связь один-к-немногим.

На самом деле, является скрытой реализацией связи немногие-ко-многим, которая, как правило, появляется когда одна из сущностей является справочной (пользовательским перечислением).

В качестве примера можно взять посты и тэги - у одного тэга может быть неограниченное количество постов. А вот для одного поста сложно более десятка тэгов.

Пример из ТА - программы и упражнения. Программы состоят из нескольких упражнений, а упражнение может входить в неограниченное кол-во программ.

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

Моделирование связи многие-ко-многим

На самом деле большинство джоин-таблиц обеспечивают не связь многие-ко-многим, а связь немногие-ко-многим.

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

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

Чтобы придумать реальный пример связи многие-ко-многим без атрибутов - надо постараться и в рамках потока сознания у меня не вышло.

Статическая иерархия

В Проекте Э с медицинским дневником было несколько типов событий - замер, приём пищи, приём лекарств, активность и т.д.

Это один полиморфный агрегат.

Динамическая иерархия

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

Более конкретный, но вымышленный пример из TA. Сейчас есть похожая конструкция, где агрегат терапевта (содержит только имя) связан с агрегатом пользователя (содержит логин/пароль) невладеющим отношением один к одному.

Это сделано во многом в качестве заточки на то, что когда-нибудь у клиентов появится личный кабинет и они тоже смогут логиниться, но не обязаны при этом становиться терапевтами.

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

Проекция структуры состояния (объектная модель)

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

Проекция структуры состояния определяет структуру программы в рантайме - долгоживущие объекты, которые, как правило, создаются и связываются в момент запуска программы, не меняют значения своих полей в процессе жизни программы (не меняются связи между ними) и живут до завершения программы.

Тут есть очевидные исключения - ленивая инициализация системы и динамическая конфигурация.

Номенклатура блоков

Обязательными блоками являются только два вида блоков:

  1. Порты - точки входа в систему - контроллеры, слушатели очередей сообщений, обработчики событий планировщика и т.п.

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

В нетривиальных случаях дополнительно могут быть введены:

  1. Операции - набор из нескольких эффектов, который должен быть выполнен в ответ на сигнал извне (аналог метода сервиса приложения из ДДД или юзкейса из чистой архитектуры);

  2. Доменные операции - набор из нескольких эффектов, переиспользуемых в нескольких операциях;

  3. Примитивные ресурсы - ресурсы, являющиеся деталью реализации другого ресурса;

ЭА придерживается правила KISS и предлагает вводить слои и уровни абстракции только при необходимости. В частности это означает две вещи.

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

Во-вторых, в ЭА классы операций могут напрямую зависеть от классов ресурсов, а классы ресурсов могут напрямую зависеть от инфраструктуры, при условии, что в АПИ ресурсов не фигурируют типы из АПИ инфраструктуры.

Однако если в вашем контексте плюсы более сложной реализации, перевешивают минусы, то вы вольны запретить прямой доступ из портов к ресурсам даже если оперция будет состоять из одной строки делегации вызова в ресурс и/или ввести интерфейсы для ресурсов и/или инфраструктуры, даже если у этих интерфейсов будет по одной реализации.

Связи между блоками

  1. Общее ограничение - не рекомендуется более 4ёх связей между любыми блоками и настоятельно не рекомендуется более 8-ми;

  2. Операции не могут быть связаны с другими операциями;

  3. Ресурс может быть связан (входящей связью) только либо с одним другим ресурсом (быть деталью его реализации, то есть примитивным), либо с неограниченным количеством операций

Кодирование блоков

Порты, операции и ресурсы кодируются классами, которые создаются и связываются DI-контейнером.

Количество публичных методов в портах и ресурсах ограниченно разумными на ваш взгляд пределами.

Классы операций могут иметь только один публичный метод.

Доменные операции кодируются либо процедурами, получающими на вход все необходимые ресурсы, либо классами, которые инстанциируются "вручную" внутри операций и могут обращаться только к ресурсам операции.

Примеры/Паттерны структур состояния

Хранение агрегата в нескольких хранилищах

Например, файлы в TA хранятся в двух местах - метаинформация в PostgreSQL и собственно блобы в минио.

Соответственно агрегат файла представлен в системе составным ресурсом FilesStorage, который включает в себя два простых ресурса FilesMetaDataDao (сейчас в коде FilesMetaDataRepo) и MinioClient.

Трёхуровневый ресурс

Продолжая пример с файлами из ТА, публичный ресурс ExercisesRepo (сейчас в коде ExercisesService) включает в себя два ресурса - ExercisesDao (сейчас ExercisesRepo) и _[Exercises]_FilesStorage, который в свою очередь состоит из FilesMetaDataDao и MinioClient

Переиспользование ресурсов

Заканчивая пример с файлами в ТА, экземпляры класса ресурсов могут входить в несколько ресурсов и даже хранить данные "в одном месте", при условии, что эти наборы данных не пересекаются. В ТА таким примером является FilesStorage, который является частью и ресурса ExercisesRepo и ресурса ClientFilesRepo.

Закодировано это тем, что класс FilesStorage (точнее интерфейс и его реализация) вынесен в платформу и не является Spring-бином. Бины же соответствующих ресурсов определены в Spring-конфигах этих ресурсов и имеют соответствующие имена.

А на уровне хранилища наборы данных файлов клиентов и упражнений разнесены по разным бакетам, которыми конфигурируются экземпляры FilesStorage.

Ресурс логического агрегата

Для технических агрегатов одного логического агрегата (два агрегата, связанные не владеющей связью один к одному) заводится один ресурс.

Примером такого ресурса является HotelsRepo (в коде сейчас HotelsService), который отвечает за хранение отелей и их номеров, коих может быть сотни.

Репозиторий+очередь сообщений

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

Например, в Проекте Э при добавлении, изменении или удалении события дневника, данные необходимо переслать в три разных системы. И потенциально таких систем может быть много.

Теоретически, все эти четыре штуки - репоз событий и клиенты систем можно собрать в один большой ресурс. Но в этом случае есть "интуитивная" проблема - ресурс станет слишком большим, и объективная проблема - у операции появится 4 точки отказа, ошибки в которых надо обрабатывать.

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

В этом случае ресурс агрегата событий делается составным и включает в себя DAO-для хранения агрегатов и клиент очереди сообщений для публикации событий. А в случае если необходимо обеспечить гарантированную пересылку (как в Проекте Э) и требуется паттерн Transactional Outbox, то заводится ещё и ресурс-DAO для работы с таблицей-outbox-ом.

Бизнес-логика уровня ресурса

Иногда бывает так, что необходимо обеспечить какой-то инвариант над всей коллекцией агрегатов.

Самый простой и распространённый пример - обеспечение уникальности вторичного ключа агрегата. В этом случае самый простой и разумный способ это сделать делегировать эту работу СУБД.

Но бывают более сложные случаи. Например, в ТА, необходимо исключить пересечение приёмов по времени. С точки зрения надёжности, это опять же лучше сделать на уровне БД, но такое решение приведёт к утечке бизнес-логики в БД, чего в общем случае лучше избегать. Плюс придётся программировать на pl/sql :)

Поэтому в качестве альтернативы, эту проверку можно унести на уровень ресурса.

В Проекте Р есть ещё более развесистый пример. В целом там есть некие таблицы, которые последовательно декомпозируются на более мелкие, так что всё дерево таблиц становится отдельными сущностями в системе. Плюс в этих таблицах есть так называемые сквозные поля - поля которые должны иметь одно и тоже значение строки, по всей цепочки декомпозиции. А ещё эти таблицы должны обновляться и версионироваться.

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

И вот эта вся машинерия помещается на уровень ресурса и реализуется в соответствии с процедурной моделью.

Ресурс DAO данных, не маячащих в доменной модели

Продолжая пример из предыдущего раздела.

На уровне бизнес-логики эти таблички представлены агрегатом, корнем которого является сущность таблицы, у которой есть объект-значение версии и список сущностей строк таблицы. И это собственно то, как таблица выглядит в голове у пользователей и в UI.

Однако в корневой таблице может быть до 1000 строк и в дереве декомпозиции может быть до 5 уровней. А в системе есть операция изменения одного поля одной строки. И если бы эта операция создавала бы полную копию агрегата со всей 1000 строк, во всех 5 таблицах в цепочек, то она бы генерировала 5000 новых строк, из которых только 5 имеют новые значения.

Поэтому на уровне БД, есть вспомогательная таблица - версия строки, которая связывает таблицу, строку и её значения. И для работы с этой таблицей введён примитивный ресурс-DAO, который работает с информацией, которая напрямую не видна через АПИ или доменную модель.

Доменная операция считывания нескольких агрегатов

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

В этом случае стоит завести вспомогательную структуру данных (объект, DTO, DPO, представление), который включает в себя весь набор данных и код загрузки этого объекта сделать доменной операцией в виде метода companion-object этого объекта.

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

Соответственно код загрузки данных для группировки из других агрегатов оформлен в виде метода companion object-а класс RowsGroupsMetaData (доменной операции)

Resource.update(aggId: UUID, fn: (Agg?) → Agg? ): Agg?

Строго говоря, операция обновления состоит из двух эффектов - чтения и записи.

И из этих соображений, если есть операции, которые меняют разные части агрегатов, то они все должны быть оформлены отдельными классами-операций, реализация методов которых тривиальна и идентична:

  1. Загрузить агрегат;

  2. Вызвать специфичную функцию создания обновлённой версии агрегата;

  3. Сохранить результат.

Это дублирование можно сократить, заведя абстрактный класс операции MyAggUpdateOp, который через Template Method получает специализированный код обновления агрегата.

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

Ресурс для повышения уровня абстракции АПИ

Бывает так, что функциональность ресурса может быть полностью реализована каким-то библиотечным (явно или косвенно) классом. Например ресурс репозитория и клиента, очевидно, может быть реализован (прощу прощения за каламбур) интерфейсом Spring Data-репозитория или декларативного HTTP-клиента. Но при этом, детали реализации могут утечь через API ресурса.

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

А вот в случаях, когда планируете - протечки деталей реализации через АПИ лучше избегать.

Так было в Проекте Р (новом сервисе) и Проекте Ю (старом огромном монолите).

В частности, для своей логики работы Проекту Р, надо было доставать ключевые сущности Проекта Ю.

И изначально была гипотеза, что Проект Р может стать Проектом Ю 2.0 и, соответственно, новая кодовая база может стать владельцем ключевой сущности.

Но на текущий момент, ключевыми сущностями владеет Проект Ю и Проект Р достаёт их через REST API с помощью декларативного Spring HTTP-клиента.

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

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

Ресурсы с состоянием в памяти

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

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

В этом случае в классе ресурса заводится приватное поле в которое сохраняется токен или их набор.

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

Соответственно, в системе был ресурс ExternalSystemClient, у которого был внутренний ресурс ExternalSystemTokensCache, который хранил мапу с токенами. И при запросе на отправку данных во внешнюю систему, клиент шёл в кэш, тот смотрел в мапу, если находил возвращал токен из неё, а если не находил или найденный токен был протухший, то шёл во внешнюю систему и получал новый токен.

Здесь же для того чтобы избежать цикла в зависимостях, реализация клиента внешней системы (которая инкапсулировала в себе специфику HTTP-запросов) пришлось разделить на два класса - для запросов аутентификации и для запросов отправки данных.

Проекция структуры поведения (процедурная модель)

Проекцию структуры поведения я изначально взял из ФА/ФП, позже нашёл так же в СД, а совсем недавно в IODA. Структура поведения описывает структуру методов, реализующих отдельную операцию.

Номенклатура блоков

Код реализации делится на четыре типа:

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

  2. Трансформации - код, чей функцией является трансформация данных или принятие решений;

  3. Вывод - код, чьей функцией является модификация внешнего состояния;

  4. Оркестрация - код, чей функцией является определение потока данных из ввода, через трансформацию в вывод. В тривиальных случаях (методы портов или искусственно введённых операций) оркестрация может не вызывать методы, ввода, трансформации или вывода.

Связи между блоками

  1. Код трансформации может вызывать только код трансформации;

  2. Код ввода и вывода не может вызывать код других типов на своём уровне абстракции;

  3. Код ввода и вывода может быть кодом оркестрации на более низком уровне абстракции;

Кодирование блоков

  1. Код ввода, вывода и оркестрации не может содержать в себе ветвления и циклов (включая вызовы методов map, filter и им подобных), за исключением защитных выражений, для ранней эвакуации из метода;

  2. Код трансформации не может иметь когнитивную сложность более 15;

  3. Код трансформации не может обращаться к глобальному окружению (текущему времени, переменным окружения и т.п.);

  4. Методы портов, операций, доменных операций могут быть только оркестрацией;

  5. Методы ресурсов на своём уровне абстракции могут быть только вводом или выводом;

  6. Внутри ресурса (особенно составного) метод может быть оркестрацией;

  7. Бизнес-логика может быть только трансформацией;

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

Примеры/паттерны

Превращение эффекта чтения в параметр

Если в метод транформации закрался вызов метод чтения - его вызов надо заменить на аналогичный параметр. см. вынесение чтения из бизнес-логики.

Ленивая инициализация для оптимизации условной загрузки данных в коде трансформации (выделение ввода из трансформации)

Если в методе трансформации условно нужен большой кусок данных, его можно так же передать параметром, но параметр при этом сделать ленивым. см. Ленивые вычисления для реализации функциональной архитектуры.

Инъекция функции для ленивой загрузки данных в коде трансформации (выделение ввода из трансформации)

Если в методе трансформации безусловно нужен "большой" список (много маленьких или мало больших объектов) - вместо списка можно передать функцию (Key) → Data и в трансформации использовать её для загрузки данных.

Это фактически смешает бизнес-логику и io и может привести к проблемам с производительностью, но взамен сократит срок жизни объектов и снизит потребление памяти.

Так сделана загрузка картинок в трансформации генерации docx-а с программой. В этом случае распарщенные изображения всё равно хранятся в памяти в течении всего времени работы метода, но так хотя бы их сырые можно выкинуть (через ГЦ) после парсинга.

Потоковая обработка данных (выделение и ввода и вывода из трансформации)

Если датасет не помещается в память - добро пожаловать в потоковую обработку данных.

В простом случае это просто:

fun resizeImages() {
  val images: Sequence<Pair<UUID, ByteArray>> = imagesRepo.findAllAsSeq()

  images.mapValues { bytes -> bytes.toBufferedImage() }
        .mapValues { img -> img.resizeTo(128, 128) }
        .chunked(batchSize)
        .forEach { batch -> imagesRepo.saveAll(batch)}
}

Здесь resizeImages - это оркестрация, findAllAsSeq и saveAll - ввод-вывод, а toBufferedImage и resizeTo - трансформация.

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

Тут уже, имхо, без разницы что у вас будет императивная мешанина логики и вывода, что адский конструктор адского конвейера, который собирает простые блоки - тут уже каждый выживает как может.

Превращение эффекта вывода в данные (выделение вывода из трансформации)

Если в метод трансормации закрался вывод - надо собрать параметры этого вызова в структуру данных и добавить её в возвращаемое значение метода. см. Шаг 3: выделение записи связки коробок с коробами из бизнес-логики.

Выделение логики из оркестрации

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

Процесс проектирования

Проходить этот процесс полностью каждый раз не обязательно - это стоит делать только при решении сложных (для вас на текущий момент) задач. При этом стоит отметить, что первые несколько задач, реализуемых по ЭА можно считать сложными и их лучше прогнать по полному процессу, чтобы "загнать его на подкороку".

Так же все эти диаграммы являются "write-only", после перехода к реализации фичи их необязательно актуализировать. И уж точно не стоит иметь Один Большой Документ В Котором Описана Актуальная Архитектура Кода.

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

Построение ER-диаграммы

На этом этапе определяются сущности и агрегаты ядра системы. Формальной методики нет - ищите существительные в требованиях и творите исхода из собственного опыта и "здравого смысла".

Тут важно "отключиться" от реализации и проектировать сущности и агрегаты именно с точки зрения пользователя/бизнеса/эксперта.

Проектирование АПИ

На этом этапе определяются операции, доступные извне системы, сейчас как правило в виде REST API. Формальной методики нет - ищите глаголы в требованиях, берите хороший гайдлайн проектирования REST API и творите исходя из собственного опыта и "здравого смысла".

Определение списка интеграций

На этом этапе определяется список интеграций и их АПИ, с которыми система должна взаимодействовать в процессе своей работы. Ищите их в требованиях.

Построение диаграммы эффектов

  1. Все агрегаты и интеграции, найденные на предыдущих шагах добавляются на диаграмму в качестве ресурсов;

  2. Все операции АПИ переносятся в качестве ресурсов;

  3. Операции связываются с ресурсами эффектами;

    1. В процессе при необходимости, вводятся доменные операции;

  4. Прогоняется этап первичной кластеризации диаграммы эффектов.

    1. Получившиеся кластеры становятся сложными ресурсами, а некластеризованные операции - операциями.

Построение структурной диаграммы

Формальной методики у меня прямо сейчас в голове нет. Скорее всего, можно за основу взять структурный дизайн и построение DFD-диаграммы. Также можно вдохновиться идеями Stratified Design в интерпретации Норманда и немца с непроизносимой фамилией (IODA Architecture, Integration Operation Segregation Principle) и барьера абстракции из SICP. Ещё у Марка Симана есть интересная идея фрактального архитектуры в Code That Fits in Your Head : Heuristics for Software Engineering. Ну и с классикой типа Чистого кода и Совершенного кода тоже стоит ознакомиться.

Но вообще, кажется что-то в таком духе должно сработать:

  1. заводите корневой блок оркестрации для операции

  2. расписываете верхнеуровневые шаги операции - что и откуда берёте, что с этим делаете, что и куда складываете, и заводите для них соответствующие блоки;

  3. рекурсивно повторить до разумных пределов;


Очевидно, в зависимости от характера задачи, ER-диаграмми, список интеграций или АПИ могут быть не нужны и из делать не надо в этом случае.

Раскладка кода по пакетам

Раскладка кода является "мягкой" рекомендацией и её можно менять как угодно по вашему усмотрению или вообще не использовать.

Прикладной код

Код приложения в первую очередь делится на два пакета (слоя) - app и domain. Эти слои навеяны Lean Architecture и определяют "What the system does" и "What the system is".

В пакет app попадают порты, операции и доменные операции, используемые одним подпакетом пакета app, в domain - сущности/агрегаты, ресурсы и доменные операции, используемые несколькими подпакетами пакета app.

Пакет app

В зависимости от ситуации, пакет app может быть разбит на подпакеты по следующим принципам:

  1. Теоретически идеальный - повторять структуру требований в формате юзкейсов;

  2. Повторять структуру приложений-клиентов и их UI;

  3. Повторять структуру пакета domain;

  4. По фичам. Определение того, что такое фича оставляется на откуп разработчика. Для определения границ фич я использую следующую эвристику - на момент постановки задачи как правило очевидно, является ли эта задача изменением существующей фичи, добавлением новой подфичи или полностью новой фичей.

Пакет domain

Изначально разбивается по ресурсам. Потом - как-то. Как вариант можно вдохновиться идеями из этапа оптимизации кластеров алгоритма кластеризации диаграммы эффектов.

Пакет ресурса

Класс представляющий ресурс помещается в корень пакета. Если ресурс является ресурсом агрегата, то сущности помещаются в подпакет model, представления в подпакет views, а классы доступа к данным (если это не корневой класс ресурса, работающий с моделью) - в подпакет persistence. При необходимости можно завести подпакет commands.

Инфраструктурный и библиотечный код

На любом уровне можно добавить пакеты platform и infra.

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

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

В своём проекте вы можете выбрать другие стандартные имена или вообще выбирать подходящие имена в каждом конкретном случае.


Всё вместе это выглядит так:

  • <org.my>

    • platform - код, который потенциально можно переиспользовать в других приложениях в других предметных областях;

      • lib1

      • lib2

    • <org.my.app-name>

      • apps - приложения системы (порты и операции)

        • app1 - приложение/API приложения под роль клиента (пользователь, админ, ДевОпс, разработчик) или UX (веб-версия, МП)

        • app2

      • domain - ядро системы - ресурсы, управляемые организацией-разработчиком

        • resource1

          • commands

          • model

          • persistence

          • views

        • resource2

      • i9ns - интеграции - ресурсы, управляемые внешними организациями

        • resource1

        • resource2

      • infra - фабричный и/или адаптационный код компонентов, необходимых для работы системы

      • platform - библиотечный код (как правило - расширения стандартной библиотеки и фреймворков), необходимый для работы системы