
Примечание: Этот пост является адаптированной расшифровкой моего одноимённого доклада на Joker' 24. |
Введение
Я пишу коммерческий код с 2005 года и с 2014 года ищу способ систематически писать хороший код.
В рамках этих поисков я изучил всю популярную литературу о хорошем коде и его дизайне — от «Чистого кода» Анкл Боба до «DDD» Эрика Эванса. Однако все популярные подходы в значительной степени субъективны: они не дают объективного и последовательного судьи, который бы решал, какой код лучше.
Например, в чистом коде я до сих пор не знаю способа за конечное время дать ответ на вопрос «Сколько уровней абстракции в этой функции?». А если взять DDD — то я до сих пор не знаю способа, который бы позволял стабильно и за конечное время находить границы между ограниченными контекстами (прошу прощения за каламбур) или агрегатами.
Эта неопределённость ведёт к длительным дискуссиям на ревью и в голове разработчика о том, какой из способов является наилучшим для решения задачи. А после этих дискуссий, каждый из участников (включая того дилетанта в собственной голове) остаётся при своём мнении.
Отчаявшись научиться писать стабильно хороший объектно‑ориентированный код, в 2016 году я пошёл в сторону функционального программирования и архитектуры. Там с детерминированностью было получше: если в коде нет побочных эффектов (ввода‑вывода, оператора присваивания и чтения глобальных переменных) — то код хороший, если есть — плохой. Однако как затащить в коммерческий проект и, главное, собственную голову свободные монады и их интерпретаторы — я так и не понял.
Поэтому в 2020 году поиски своего Святого Грааля я продолжил в «эзотерических» и древних книгах. Одной из таких книг стал «Структурный дизайн» Ларри Константина. И в этой книге я, наконец, нашёл простой и понятный принцип, который лёг в основу моего текущего подхода к проектированию и кодированию, и для которого можно быстро и однозначно дать ответ, соответствует ли тот или иной кусочек кода этому принципу или нет.
В этом посте я даю краткий экскурс в идею сбалансированной формы системы из структурного дизайна и рассказываю на примере трёх кейсов о результатах применения этой идеи в моей коммерческой практике.
Экспресс-курс структурного дизайна
Структурный дизайн — это методология разработки ПО, которую Ларри Константин и Эдвард Йордан начали разрабатывать в 60-х годах прошлого века и в итоге опубликовали в одноимённой книге в 1977 году (доступна для чтения после бесплатной регистрации).
Самым известным наследием структурного дизайна стали понятия cohesion и coupling — они впервые были введены именно в этой книге.
Но на мой взгляд, незаслуженно была забыта другая идея из структурного дизайна — transform‑centered morphology (aka balanced system).
Примечание: Transform‑centered morphology — это термин из оригинальной книги. Однако он звучит неуклюже даже в английском варианте, поэтому далее по тексту я, вслед за Пейдж‑Джонсом, автором «The practical guide to structured systems design» (следующей книги по структурному дизайну), буду использовать термин balanced system — сбалансированная система. |
Идея предельно проста: при разработке код надо разделять на четыре типа — управляющий код, код чтения данных, код трансформации и код записи данных.
И если систему, разработанную с разделением кода на эти типы, представить в виде структурной схемы, то получится примерно такая картинка (здесь и далее все картинки кликабельные):

Примечание: на картинке узлы называются модулями, однако следует помнить, что книге почти 50 лет и в ней термину модуль даётся следующее определение: «A module is a lexically contiguous sequence of program statements, bounded by boundary elements, having an aggregate identifier». Если переводить на русский, то модуль — это лексически непрерывная последовательность программных выражений, ограниченная граничными элементами и имеющая совокупный идентификатор. То есть это метод, в современной терминологии. Соответственно, стрелками от блока к блоку обозначаются вызовы методов, а маленькими стрелками вдоль стрелок‑вызовов обозначается поток данных между методами. |
Здесь четыре типа кода обозначены цветами:
Красный — код чтения данных. По стрелкам потока данных видно, что этот код берёт данные «из ниоткуда» (на самом деле — с диска, из БД, из сети, из глобальных переменных и т. д.) и заносит их в систему.
Зелёный — код трансформации. По стрелкам потока данных видно, что этот код что‑то получает на вход и что‑то возвращает. Притом по стрелкам вызовов видно, что код трансформаций сам не обращается к коду ввода или вывода, соответственно, все нужные ему данные он получает «сверху» и все результаты своей работы возвращает «наверх» (спойлер: если это вам напоминает чистые функции, то вы правы).
Жёлтый — код записи данных. По стрелкам потока данных видно, что этот код «сливает данные в никуда» (на самом деле — на диск, в БД, в сеть, в глобальные переменные и т. д.).
Голубой — код координации. Это код, который никто не вызывает (на самом деле в оригинале — код, который вызывается ОС, а в современных реалиях — код, который вызывается фреймворком), но который вызывает все другие виды кода и определяет поток данных между ними.
В книге Константин пишет, что он проводил эмпирические исследования, которые показали корреляцию между соответствием системы этой форме и низкой стоимостью её разработки и поддержки.
Если перекладывать эту абстрактную модель на современные реалии (Spring‑) разработки, то она будет выглядеть так:

На первый взгляд, все термины в ней привычные и знакомые — уверен, практически все разработчики если не работали с, то хотя бы слышали про сервисы приложения (Application Service), доменные сервисы (Domain Service), гейтвеи и репозитории (Gateway и Repository) и Data Transfer Objects.
Но есть две детали, которые существенно отличают эту модель от устройства большинства коммерческих кодовых баз и ведут к существенному упрощению кодовых баз и, как следствие, ускорению работы команды и самого кода.
Во‑первых, метод доменного сервиса не выполняет никакого ввода‑вывода. Во‑вторых, все типы кода обмениваются между собой DTO‑шками — простыми структурами данных, не содержащими в себе автомагии (такой как ленивая загрузка и дёрти чекинг в JPA‑сущностях).
Давайте посмотрим, что будет, если применять эту идею в современной разработке и приводить форму систем к сбалансированной.
Кейс №1: Project Barcoder
И начнём мы с кусочка кода из Project Barcoder — внутренней системы предприятия для автоматизации бизнес‑процесса архивации отчётных документов.
Бизнес-процесс
Обобщённо бизнес‑процесс следующий:
Филиалы предприятия собирают отчётность за один или более периодов в подручную тару (коробки, в коде — Box);
Экспедиторы доставляют коробки в пункты сбора (ПС);
Специалисты ПС переупаковывают документы из коробок в стандартизированные коробы (в коде — OsgBox);
Специалисты ПС передают коробы в специализированную организацию для долговременного хранения;
Бизнес‑правила:
В одной коробке могут содержаться документы за несколько отчётных периодов;
В одном коробе могут содержаться документы за несколько отчётных периодов;
Для каждого отчётного периода коробки необходимо сохранять привязку, в какие коробы он попал;
При переупаковке допустимы только два варианта:
Переупаковка из одной коробки в несколько коробов;
Переупаковка нескольких коробок в один короб.
В этом кейсе контроль сбалансированности системы помог мне на ревью отловить проблему в работе с БД, которую я пропустил при первой итерации ревью методом пристального вглядывания, а также помог в целом упростить код. Давайте посмотрим, как это было.
До применения принципов структурного дизайна
В процессе разработки системы мне принесли на ревью код реализации переупаковки коробок в короба и сначала я его ревьювил методом пристального вглядывания, начиная с корневого метода сервиса приложения:
@Transactional
open fun repackage(boxIds: List<Long>, osgBoxes: List<OsgBoxDto>) {
if (boxIds.isEmpty() || osgBoxes.isEmpty() || (boxIds.size > 1 && osgBoxes.size > 1)) {
throw OsgRepackageIncorrectAmountException("Допустима перепаковка только одной коробки в несколько коробов, либо нескольких коробок в один короб")
}
val osgIds = osgBoxes.map { it.osgBoxId }
if (osgService.hasAnyLinks(boxIds, osgIds)) {
throw OsgRepackageBoxAlreadyLinkedException("Некоторые из коробок или коробов уже имеют привязку")
}
if (boxIds.size == 1) {
repackageSingleBox(boxIds.single(), osgBoxes)
} else {
repackageMultipleBoxes(boxIds, osgBoxes.single())
}
}По большому счёту, про этот код сложно сказать что‑то плохое — он достаточно короткий, аккуратный и простой. Пожалуй, название ошибки OsgRepackageIncorrectAmountException не самое удачное, да условие boxIds.size == 1 можно было бы вынести в метод с названием, отражающим бизнес‑семантику — но нельзя сказать, что это кардинально улучшит качество кода. Тем не менее, всё равно напишем это в замечания и перейдём к следующему методу — обработке кейса перепаковки одной коробки в несколько коробов:
private fun repackageSingleBox(
boxId: Long,
osgBoxes: List<OsgBoxDto>,
) {
val box = boxRepo.findByIdOrNull(boxId) ?: throw BoxNotFoundException("Короб не найден")
box.reportingIntervals = toReportingIntervals(osgBoxes)
boxRepo.save(box) // В проекте используется Spring Data JDBC, поэтому вызов save обязателен
osgService.insertBoxesToOsgLinks(osgBoxes.map { it.osgBoxId }, listOf(boxId))
}Тут код уже совсем простой и линейный — загрузили коробку, перепаковали, сохранили (в проекте используется Spring Data JDBC, поэтому save надо вызывать явно), сохранили привязку коробки к коробам.
Наверное, в этом коде можно докопаться, что у метода osgService.insertBoxesToOsgLinks слишком низкий уровень абстракции и он должен принимать на вход сущности, а не идентификаторы. Или нельзя — ведь тогда этот метод будет сложнее использовать, например, в случае, когда привязка выполняется напрямую по команде от фронта и передаются только ИДы
