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

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

Визуализации

efa1.drawio
Figure 1. Вариант визуализации №1
efa2.drawio
Figure 2. Вариант визуализации №2
efa3.drawio
Figure 3. Вариант визуализации №3
efa4.drawio
Figure 4. Вариант визуализации №4
efa5.drawio
Figure 5. Вариант визуализации №5
efa6.drawio
Figure 6. Вариант визуализации №6
efa7.drawio
Figure 7. Вариант визуализации №7
efa8
Figure 8. Вариант визуализации №8

Черновик описания

Введение

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

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

Для краткости свой шаблон я буду называть Эргономичной функциональной архитектурой (сокращённо ЭФА).

Характерные черты ЭФА

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

Разделение ввода-вывода и бизнес-логики

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

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

Ограничение на размер блоков

Другое мнение, которого я придерживаюсь, заключается в том, что людям проще работать со "штуками" состоящими из небольшого количества элементов.

У сущности (структуры данных) должно быть не много полей=. У сервиса (набора функций/методов) должно быть не много зависимостей и методов. В пакете (модуле/директории) должно быть не много классов (файлов) и подпаектов (директорий).

За неимением более обоснованной альтернативы, "не много" значит 7 +/- 2.

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

Концентрация контракта в одном месте

Следующее мнение, лежащее в основе ЭФА, заключается в том, весь контракт операции должен помещаться на одном экране.

Под контрактом я понимаю не только структуру входных и выходных параметров, но и:

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

  2. Какие возможны ошибочные исходы выполнения операции.

Для того, чтобы обеспечить видимость эффектов операции, ЭФА (в дополнение к разделению бизенс-логики и ввода-вывода и декомпозиции системы на ресурсы) накладывает ограничения на глубину стэка вызовов методов с эффектами.

А для того, чтобы обеспечить видимость исходов операции, в отличие от ФА по Влашину, ЭФА накладывает отдаёт предпочтение охранным-выражениям (а не Eather/Result/Try-монаде) для раннего прерывания операции в случае ошибки.

Бытовой язык домена

Продолжая тему монад, в ЭФА я отказался от хардкорного ФП с монадами, аппликативами, категориями Клейсли.

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

Кроме того, конкретно в Kotlin-е, нет синтаксической поддержки монад и код с ними начинает содержать много лишнего шума (flatMap в общем случае или bind в случае Arrow).

Отказ от инверсии зависимостей

В отличие от ФА по Влашину (и гексагональной/чистой архитектуры) ЭФА допускает зависимость портов/операций (аналог Workflows у Влашина, юз кейсов у Мартина, сервисов приложения у Эванса) от конкретных классов реализации ресурсов (репозиториев, клиентов внешних систем и т.п.).

Это обусловлено тремя соображениями.

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

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

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

Поэтому в случаях где существует только одна реализация, ЭФА рекомендует воздержаться от определения артефакта интерфейса.

Концепция ресурса

Наконец, в отличие от слоёной и функциональной архитектур, всё состояние системы поделено на ресурсы, вынесенные на архитектурный уровень.

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

Номенклатура стандартных блоков ЭФА

Синтаксически ЭФА разделяет три вида кода:

  1. Структура данных - неизменяемый набор данных. В случае Kotlin - data class. Опционально может класс может содержать методы, реализованные в виде чистых функций.

  2. Объект - чёрный ящик, обладающий состоянием, скрытым за методами его мутации. В Kotlin - обычный класс, который в своих полях содержит любо изменяемые структуры данных, либо ссылки на абстракции доступа к внепроцессному состоянию (подключение к БД, HTTP-клиент, клиент SMTP-сервера);

  3. Функция - чистая функция. В Kotlin - функция верхнего уровня (без ссылок на переменные верхнего уровня) или метод объекта-синглтона.

Эти конструкции используются для реализации стандартных блоков ЭФА:

  1. Сущности. Структуры данных с полем id, представляющие записи, хранимые ресурсами;

  2. Объекты значения. Структуры данных, на которые ссылаются сущности;

  3. DTO ресурсов. Структуры данных, представляющие команды на модификацию ресурсов, либо представления сущностей;

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

  5. Ресурсы (репозитории, клиенты внешних систем, очереди сообщений). Объекты, выступающие контейнерами сущностей;

  6. Операции. Объекты, реализующие операции системы, завис

Эргономичная архитектура с высоты птичьего полёта

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

Ресурсы

В основе эффективной архитектуры лежит ресурс - изменяемый контейнер записей. Самым распространённым примером ресурса является таблица в РСУБД (тип коллекции - map*).

  • - Метафора ресурса как изменяемого контейнера (коллекции, динамической структуры данных) наверняка имеет свои пределы и в какой-то момент сломается. Но за те 4 года, в течении которых я использую эту ЭФА, диаграмму эффектов и эту метафору - она ни разу не сломалась.

Другие примеры ресурсов — топик в брокере сообщений (тип контейнера - source, sink, queue, в зависимости набора операций, доступных системе), REST API внешней системы с полным CRUD-ом (тип контейнера - map), read only REST API внешней системы (тип коллекции - var (контейнер скаляра), list, map etc, в зависимости от API), сервис отправки почты (тип контейнера - sink), глобальная переменная (тип контейнера - зависит от типа переменной).

В коде приложения ресурс представлен минимум двумя структурами (классами, структурами данных, записями и т.д.) - структурой записи и структурой коллекции. В данном проекте примером простого ресурса являются заказы, представленные классами Order (структура записи) и OrdersRepo (структура коллекции).

Помимо записи и коллекции модули ресурсов могут включить в себя:

  • DTO с запросами на модификации;

  • DTO с представлениями ресурсов;

  • Функции трансформации, обработки и реализации бизнес-правил, зависящие только от данного ресурса

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

  • Записи

    • Неизменяемые

    • Небольшое (на усмотрение команды, рекомендуемое значение - 10-15) количество полей;

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

  • Коллекции

    • Идеально — декларативные (см. Spring Data, например)

    • Небольшая (на усмотрение команды, рекомендуемое значение - ⇐ 3) цикломатическая сложность;

    • Небольшая (на усмотрение команды, рекомендуемое значение - ⇐ 2) глубина дерева вызовов;

Порты

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

Другие примеры портов — обработчик иных механизмов RPC, обработчик события в in-memory или разделяемой шине событий, обработчик событий планировщика.

На код реализации портов накладываются следующие ограничения:

  • Может содержать не более одного вызова метода, изменяющего ресурс;

  • Не может содержать бизнес-логику;

  • Может содержать ветвление только для выбора способа представления ответа;

Эффекты

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

В коде эффект - это метод класса ресурса.

Простые операции

Группы эффектов образуют операции.

И если группа состоит из одного эффекта - мы получаем простую операцию.

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

Примером такого простейшего случая является порт запроса брони и ресурс брони.

Однако не всегда всё так просто.

Составные ресурсы

Иногда два ресурса "сильно связаны".

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

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

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

Примером сложного ресурса является отель, представленный на уровне системы классом HotelsService, и реализованный с помощью двух простых ресурсов — отель (Hotel + HotelsRepo) и номер (Room + RoomType + RoomsRepo).

На код реализации методов сложных эффектов накладываются следующие ограничения:

  • Небольшое (на усмотрение команды, рекомендуемое значение - ⇐ 2-4) количество зависимостей (коллабораторов);

  • Небольшая (на усмотрение команды, рекомендуемое значение - ⇐ 7) цикломатическая сложность;

Сложные операции

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

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

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

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

На код реализации сложных операций накладываются следующие ограничения:

  • Небольшое (на усмотрение команды, рекомендуемое значение - ⇐ 3-5) количество зависимостей (коллабораторов);

  • Небольшая (на усмотрение команды, рекомендуемое значение - ⇐ 7) цикломатическая сложность;

Декомпозиция прикладного кода

Предыдущая версия Эффективной архитектуры была объектно-ориентированной - все операции (вместе с портами) помещались в один общий модуль с одним из ресурсов. А сами модули при этом образовывали однородное ядро системы.

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

Поэтому в текущей версии я разделяю ресурсы и операции.

Что приводит к очевидному разделению прикладного кода как минимум на два слоя — ядра и приложения.

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

Декомпозиция ядра

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

Декомпозиция приложения

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

Структура кодовой базы

Опционально кодовую базу рекомендуется структурировать в соответствии с шаблоном:

  • <org.my>

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

    • lib2

  • <org.my.app-name>

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

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

      • app2

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

      • resource1

      • resource2

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

      • resource1

      • resource2

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

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

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

Практические применения

По актуальной версии Эффективной архитектуры пока реализован только некоммерческий демонстрационный проект.

Однако по более ранним версиям было реализванно несколько коммерческих проектов размером до двух человеко-лет к текущему моменту.