Структура эргономичных программ

November 29, 2021

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

Абстракция информационной системы

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

abstract is

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

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

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

Хорошей в смысле суммарной стоимости реализации всех версий этой коробочки за всё время её жизни. Для этого нам нужна реализация, которая минимизирует две характеристики:

  1. Количество изменений в коде, необходимых для реализации изменений в требованиях
  2. Количество регрессий

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

В одной из первых работ на эту тему "On the Criteria To Be Used in Decomposing Systems into Modules" Парнас говорит, что модуль должен инкапсулировать сложное решение или решение, которое с высокой вероятностью измениться. Но что делать в разработке ИС, где сложных решений практически не осталось, а вероятность изменения решения даже заказчик не может оценить?

В Domain-Driven Design модулям посвящено чуть более 1% текста книги (5 страниц). К сожалению, 5 страниц воды.

В чистой архитектуре этому вопросу посвящено меньше 5% книги (16 страниц). Неписаных Саймоном Брауном, а не анкл Бобом. Тоже вода.

У анкл Боба есть принципы проектирования пакетов. Они говорят о том, по каким принципам надо оценивать дизайн. Но не говорят ни как хороший дизайн выглядит, ни как его добиться.

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

Результатом станет приложение, которое легко (и не дорого) развивать.

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

Слоёная архитектура

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

  1. Представление - отвечает за интерфейс взаимодействия с ИС пользователями (людьми или машинами)
  2. Ядро - отвечает за то, какая информация хранится в системе и как она обрабатывается
  3. Инфраструктура - отвечает за взаимодействие ИС с внешними системами
is to layers

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

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

Если разбивать систему на пакеты по техническим аспектам - плохо, то как - хорошо? Вопрос на миллион.

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

Эргономичный дизайн

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

layers to modules

Домен

Я не буду изобретать велосипед, и определение слоя домена возьму из классики:

Responsible for representing concepts of the business, information about the business situation, and business rules. State that reflects the business situation is controlled and used here, even though the technical details of storing it are delegated to the infrastructure. This layer is the heart of business software.

Эрик Эванс, Domain-Driven Design

Лишь подчеркну, что "состояние [информационной системы] контролируется слоем домена".

Инструментарий описания состояния я так же беру из DDD - значения (Value Objects), сущности (Entities) и агрегаты (Aggregates).

Единицей изменения состояния информационной системы является агрегат. Храниться агрегат может в различных местах - чаще всего в БД, возможно, во внешнем сервисе, иногда хранится в памяти или в файле.

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

Typically you’ll have one Module for one or a few Aggregates (10) that are cohesive, if only by reference.

Implementing DDD, Vaughn Vernon

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

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

Приложение

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

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

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

Серая зона

Трансформации, задействующие несколько агрегатов

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

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

Состояние специфичное для одного юз кейса

Ещё неоднозначный случай - состояние специфичное для группы операций из одного модуля слоя приложения. Для этого случая я вижу два варианта действий:

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

Переиспользуемые юз кейсы

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

Характеристики структуры модулей ядра ИС

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

  1. Принцип ацикличного графа зависимостей
  2. Принцип сокрытия информации
  3. Принцип стабильных зависимостей
  4. Принципы высокой связности и низкой связанности
  5. Принцип единственности ответственности
  6. Принцип расширения поведения, за счёт нового кода

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

Фасады к инфраструктуре

В современной разработке бэков ИС на Spring инфраструктурного кода будет немного или не будет вовсе. Весь инфраструктурный код будет в библиотеках, а в ИС останутся только фасады, адаптирующие библиотеки к системе. Чтобы не привносить лишнюю сложность в систему, эти фасады стоит помещать прямо в слой домена (для абстракций REST-ресурсов внешних сервисов), либо приложения (для абстракций внешних сервисов). Но в этом случае важно следить за тем, чтобы зависимости на инфраструктуру не протекали из модулей-фасадов. Для этого можно воспользоваться библиотекой ArchUnit.

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

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

Структура реализации операции системы

Все операции ИС укладываются в одну универсальную последовательность - что-то откуда-то считать, как-то это преобразовать и куда-то записать. С точки зрения Spring-разработчика, это может показаться не так - например, в случае метода, который выдаёт по HTTP одну строку из БД, без каких-либо преобразований. Но если раскрыть всю автомагию, то там будет всё та же структура:

  1. Считать HTTP-запрос из сокета
  2. Считать кортеж из БД из другого сокета
  3. Как правило, преобразовать кортеж в объект и в любом случае преобразовать его в массив байт
  4. Записать этот массив байт в сокет

А операции изменения состояния системы, даже на нашем уровне абстракции содержат все три шага в явном виде.

Ещё в 60-70-ых годах, древние архитекторы раскрыли секрет дешёвых в поддержке программ - структурный дизайн.

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

modules to structured design

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

Реализация управляющего блока

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

Модуль управления должен на одном уровне собирать контракт операции:

  1. Какое состояние является входом операции
  2. Какие трансформации выполняются в рамках операции
  3. Какое стояние модифицируется в результате выполнения операции

На мой взгляд, эта задача лучше всего решается подходом "Railway-oriented programming".

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

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

  1. Тело управляющего блока состоит из последовательности (без условий и циклов) присваиваний результатов вызова функций переменным, где каждый следующий вызов принимает в качестве аргумента одну или более переменную вычисленную на предыдущих шагах
  2. Ранняя "эвакуация" из метода выполняется посредством защитного if-а и return-а или throw-а

Реализация ввода/вывода

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

Дело в том, что в 2021-ом году в ИС-ах модули ввода/вывода либо вообще де-факто отсутствуют - разработчики описывают только интерфейсы репозиториев, а реализуются они автомагически Spring Data, либо тривиальные - разработчики декларативно собирают объект описывающий HTTP-запрос, отдают его библиотеке и она возвращает результат, возможно, сразу в виде объекта предметной области.

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

Это необходимо для того чтобы:

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

Реализация трансформаций

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

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

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

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

Ради чего все эти лишения и ограничения? Ради минимизации стоимости развития ИС за счёт минимизации количества регрессий, за счёт предельного упрощения покрытия трансформаций тестами и минимизации временнОй связанности (temporal coupling).

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

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

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

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

Заключение

Если свести весь этот пост в один список рекомендаций, то получится так:

  1. Проектируйте слой ядра приложения
  2. Слой ядра приложения должен отражать предметную область и функции системы
  3. Граф зависимостей модулей и классов должен быть ацикличным
  4. При декомпозиции системы на модули стремитесь к тому, чтобы максимальное количество изменений в требованиях влекло за собой изменения только в одном модуле
  5. Минимизируйте количество связей между модулями
  6. Следите за тем, чтобы менее стабильные модули зависели от более стабильных, но не наоборот
  7. Разделяйте реализацию операций на верхнеуровневое описание операции, трансформации и ввод-вывод
  8. Верхнеуровневое описание операции системы должно собирать в себе (на одном экране) контракт операции - что на вход, как это трансформируется, что на выход
  9. Трансформации должны быть реализованы в декларативном стиле
  10. Ввод-вывод должен быть максимально простым

Применив все эти рекомендации, вы получите примерно такую глобальную структуру ИС:

integrated

Но в этом списке есть рекомендация, которой не очень понятно как следовать. Что значит "проектируйте слой ядра приложения"? Вот у вас есть требования, вам надо спроектировать ядро - как это сделать? Ответ в следующем посте.