Алексей Меленчук
вся записка
дата
объём 6 мин чтения

Границы модулей дороже, чем стиль кода

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

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

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

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

Это перевёрнутый приоритет. Стиль кода — вопрос, у которого любой ответ приемлем, лишь бы он был один. Границы — вопрос, неверный ответ на который стоит года работы.

Как это выглядит через три года

Начинается безобидно. Модулю заказов понадобилось имя пользователя. Импортировать модель пользователя — одна строка, и она работает.

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

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

Договорённость не работает

Записанное в вики «модули общаются только через публичные интерфейсы» живёт до первого срочного релиза. Потом кто-то делает прямой импорт, потому что горит, и ставит комментарий // TODO: переделать. Комментарий живёт вечно, а импорт становится прецедентом.

Правило, которое не ломает сборку, — это пожелание.

Хорошая новость: проверить это дёшево. В экосистеме каждого языка есть инструмент архитектурных тестов, и настройка занимает день.

  • В Java — ArchUnit: правила пишутся как обычные тесты.
  • В PHP — Deptrac: слои и разрешённые зависимости в YAML.
  • В Python — import-linter: контракты на пакеты.
  • В C++ границу обычно держат физически, через раздельные цели сборки и видимость заголовков.

Инструмент вторичен. Первично то, что нарушение падает в CI и не проходит в основную ветку.

Что описывать

Не «слои» в абстрактном смысле. Три конкретные вещи.

Публичная поверхность модуля. Один файл или пакет, через который разрешено обращаться. Всё остальное — внутренности. В большинстве языков это выражается размещением: пакет orders публичный, orders.internal — нет.

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

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

Где граница проходит на самом деле

Здесь у меня позиция, с которой спорят.

Модули стоит резать не по техническим слоям, а по бизнес-способностям. Не «контроллеры / сервисы / репозитории», а «заказы / оплата / доставка», и внутри каждого — свои контроллеры, сервисы и репозитории.

Причина простая: изменения приходят по бизнес-способностям. Задача звучит «поменять правила расчёта доставки», а не «поменять все репозитории». При техническом делении такая задача трогает все три слоя во всех модулях. При делении по способностям — один каталог.

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

Как модули общаются

Границу описали. Дальше вопрос, через что именно проходит вызов, и здесь три варианта с разными свойствами.

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

Событие внутри процесса. A публикует «заказ создан», B подписан. A не знает о существовании B — связность минимальна. Плата: последовательность выполнения перестаёт быть видимой в коде, и на вопрос «что произойдёт после создания заказа» нельзя ответить, не поискав подписчиков.

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

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

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

Общая база — граница, которой нет

Отдельно про случай, когда модули формально разделены, а таблицы общие.

Прямой SELECT в таблицу соседнего модуля закрепляет её схему навсегда. Владелец больше не может переименовать колонку, изменить тип или вынести данные — он не знает, кто на них смотрит. Тест архитектуры этого не поймает: импорта нет, есть строка с SQL.

Единственный способ, который я знаю, — договорённость плюс проверка по имени таблицы в запросах модуля. Грубо, но работает: список таблиц, разрешённых модулю, и проверка в CI. Уродливо, зато ловит ровно то, что не ловится анализом импортов.

С чего начать в существующем проекте

Переписывать не надо. Порядок, который работает на живом коде:

  1. Найти самый болезненный узел — модуль, который трогают чаще всего и который тянет за собой остальные. Обычно все его знают без анализа.
  2. Описать его публичную поверхность как есть, ничего не меняя.
  3. Написать тест: никто не импортирует внутренности этого модуля мимо поверхности. Тест упадёт, показав список нарушений.
  4. Занести список в исключения. Да, целиком.
  5. Правило: новых исключений не добавляем, старые убираем по одному, когда рядом и так что-то правим.

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

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