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

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

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

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

Введение

В своей книге 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-модели и ECS архитектуры.

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

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

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

Сущности бывают двух типов:

  1. Коренная сущность. Это самостоятельная сущность обладающая собственной идентичностью - заказ, клиент, продукт и т. д.;

  2. Аспект другой сущности. Сущность-аспект, это фактически компонент, который был повышен до сущности по дизайнерским или техническим причинам. Например, если в предметной области встречаются заказы с сотнями позиций, то в модели позиция может быть сделана сущностью-аспектом, для того чтобы повысить эффективность работы с БД в реализации модели. Или компонент "Адрес доставки" сущности "Заказ" может быть сделан сущностью-аспектом, если эта информация нужна модулю, отличному от того, который управляет коренной сущностью "Заказ";

Коренная сущность со всеми её компонентами и аспектами называется агрегатом.

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

В модели данных ЭА есть только связи один-к-одному и один-ко-многим. Связей многие-ко-многим нет.

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

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

Здесь и далее в качестве примеров кодирования я использую терминологию Kotlin. Однако ЭА может быть реализована на любом тюринг-полном языке - хоть Си, хоть Хаскель, разница лишь в том, придётся ли при этом бороться с сами языком. Борьбы с языком не будет, если он мультипарадигменный - поддерживает и императивную, и объектную и функциональную модель программирования.

И сущности и компоненты кодируются неизменяемыми data class-ами.

Связи между сущностями и компонентами кодируются атрибутом сущности с типом компонента

Связи между сущностью-аспектом и коренной сущностью кодируются атрибутом сущности-аспекта с типом идентификатора*.

* Здесь и далее под "типом идентификатора" подразумевается простой тип, как правило - UUID, Long или String.

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

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

// Компонент
data class OrderItem(
  val quantity: Int,
  val productRef: ProductRef<UUID>
)

// Коренная-сущность
data class Order(
  val id: UUID,
  val number: String,
  val items: List<OrderItem>
)

// Сущность-ассоциация
data class ShippingAddress(
  val id: OrderRef<UUID>,
  val city: String,
  val street: String
)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вспомнить пример связи многие-ко-многим без атрибутов из своей практики я не смог.

Если фантазировать, то можно, например, привести пример связи студент <→ курс. Но в реальной практике, она почти наверняка обрастёт собственными атрибутами - как минимум оценка за курс и год прохождения.

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

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

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

Проекция структуры компонентов

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

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

В бакэндах, между классами компонентов и объектами времени выполнения в 90% случаев связь один к одному - в коде есть один класс ресурса (репозитория, DAO, сервиса, юзкейса и т.п.) и в рантайме создаётся один экземпляр этого класса. Но в некоторых случаях бывает так, что во время выполнения создаётся несколько экземпляров одного класса.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Методы портов могу содержать в себе только один вызов операции или ресурса. Когнитивная сложность метода порта не может превышать 1.

Методы портов могут содержать ветвление только для решения двух задач:

  • Выбор способа представления ответа. Например, если операция использует функциональный подход к обработке ошибок, то в контроллере допустим if/when/switch по типу результата операции для выбора кода ответа;

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

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

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

Градация сложности графов структуры компонентов

Визуально крайние точки сложности графов структуры компонентов в разрезе отдельной операции можно проиллюстрировать следующими диаграммами (картинки кликабельны):

Simple

Complex

Здесь на левой диаграмме представлена простая CRUD-операция, которая просто передаёт данные в какой-то простой ресурс, например в таблицу БД.

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

Эта операция может быть вызвана двумя разными способами - по сообщению в очереди RabbitMQ и по расписанию.

Далее, эта операция в результате своей работы модифицирует четыре ресурса - Repo, MessageQueue, ExternalSystem1 и ExternalSystem2.

При том для модификации MessageQueue, ExternalSystem1 и ExternalSystem2 она использует доменные операции.

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

То есть класс операции является корнем композиции - он прямо зависит от классов всех ресурсов, которые затрагиваются данной операцией. Это позволяет:

  • сделать очевидными потенциальные эффекты операции;

  • обеспечивать низкую сцепленность операции (за счёт ограничение на количество зависимостей компонента);

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

Наконец, ресурс Repo является сложным ресурсом, который в хранит свои данных в SQL базе данных и Minio и для работы с ними использует соответствующие примитивные ресурсы.

Чтобы немного приземлить эту диаграмму, можно представить, что это операция репликации по расписанию состояния какого-то агрегата, который включает в себя приложенные файлы. Соответственно, раз в сутки, например, эта операция достаёт текущее состояние агрегата из Repo и передаёт его другим сервисам своей системы через MessageQueue и в две внешние системы через ExternalSystem1 и ExternalSystem2.

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

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

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

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

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

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

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

Классы ресурсов

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

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

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

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

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

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

Теоретически, все эти четыре штуки - репоз событий и клиенты систем можно собрать в один большой ресурс. Но в этом случае есть "интуитивная" проблема - ресурс станет слишком большим, и объективная проблема - у операции появится 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-клиента. Но при этом, детали реализации (Criteria API или HTTP-методы, например) могут утечь через API ресурса.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  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. По ресурсам REST API;

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

Пакет domain

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

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

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

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

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

Пакеты platform

Пакеты platform содержат:

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

    • Например, в пакете myapp.app.users.platform может быть класс ДТО, который используется в нескольких контроллерах пакета app.users.

    • Или в пакете myapp.domain.users.platform может быть функция-утилита, которая используется в нескольких моделях/командах/запросах пакета myapp.domain.users.

    • Или в пакете myapp.platform.spring.jdbc могут быть функции-утилиты для класса JdbcClient, которые используются в разных пакетах ресурсов (в myapp.domain.*);

  • Определение классов компонентов для которых во время выполнения создаётся несколько экземпляров.

    • Например, это может быть компонент FileStorage(name), который является деталью реализации нескольких ресурсов и, соответственно, каждый из которых используюет собственный экземпляр - UsersRepo(usersDao, usersFileStorage), ExercisesRepo(exercisesDao, exercisesFileStorage).

Пакеты infra

Пакеты infra содержат фабрики (как декларативные, так и императивные) инфраструктурных компонентов.

Например, в пакете myapp.app.infra может быть определена Spring-конфигурация SecurityConf, задающая настройки авторизации всего приложения. Или в пакете myapp.domain.infra может быть определена Spring-конфигурация DataSourceConf, содержащая Spring-бин DataSource для всех ресурсов. Или в пакете myapp.infra может быть определена Spring-конфигурация CacheConf, создающая CacheManager, используемый в пакетах myapp.app и myapp.domain`.

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


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

  • <org.my.app-name>

    • app - порты и операции системы

      • app-module1 - модуль для раздела ТЗ/приложения-клиента/ресурса domain/ресурса REST API/фичи

      • app-module2

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

      • resource1

        • commands - ДТО команд на модификацию ресурса

        • model - доменная/концептуальная модель данных ресурса

        • persistence - персистентная модель и код работы со слоем персистанса

        • queries - ДТО сложных запросов к состоянию ресурса

        • views - ДТО представлений ресурса

      • resource2

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

      • resource1

      • resource2

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

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

      • errros - базоый класс доменных ошибок и классы стандарных доменных ошибок (ResourceNotFoundExceptoin, ResourceAlreadyExistsExeption и т.д.)

      • files_storage - класс компоентов хранилища файлов

      • kotlin - функции расширения для классов из стандартной библиотеки Kotlin

      • postgresql - функции расширения для классов JDBC драйвера PostgreSQL

      • spring

        • data - функции расширения для классов из модуля Spring Data

      • uuid - самописанная реализация UUIDv7