From 584fb6cf6c7858bf84e7719d2570c63d1296e5ae Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 16:37:10 +0300 Subject: [PATCH] editorial: revise articles 137-142 to 10/10 --- editorial/agent-rewrites/137.json | 6 +++--- editorial/agent-rewrites/138.json | 6 +++--- editorial/agent-rewrites/139.json | 2 +- editorial/agent-rewrites/140.json | 4 ++-- editorial/agent-rewrites/141.json | 4 ++-- editorial/agent-rewrites/142.json | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/editorial/agent-rewrites/137.json b/editorial/agent-rewrites/137.json index 92352af..fe12f7b 100644 --- a/editorial/agent-rewrites/137.json +++ b/editorial/agent-rewrites/137.json @@ -1,7 +1,7 @@ { "index": 137, "slug": "editorial-2024-03-mechanism-package-boundaries", - "title": "Границы пакетов: как не превратить shared-утилиту в скрытую платформу", - "excerpt": "Рабочая схема для пакета, который начинает знать чужую доменную модель: минимальный public API, запрет внутренних импортов, проверка маршрута зависимости и честные ограничения инструментов.", - "contentHtml": "

Ошибка обычно начинается с проходящего импорта. Formatter получает InvoiceStatus, чтобы вывести подпись рядом с суммой. Другой consumer берёт cache по пути platform-formatting/internal/cache, потому что так короче. Сборка проходит, TypeScript не спорит, autocomplete подсказывает нужный путь. Цена появляется позже: изменение billing enum требует выпуска formatter-а, чистка cache ломает consumer-а, а владелец зависимости неизвестен. Небольшой пакет перестаёт меняться изолированно.

\n

Тезис простой: границу пакета нельзя поручить одному инструменту. Сначала команда описывает public API. Затем runtime и компилятор ограничивают видимые точки входа. После этого статическое правило ловит запрещённые направления. Каждый слой проверяет свою часть договора. exports не заменяет архитектурное решение, TypeScript не определяет смысл доменной зависимости, а lint не видит весь динамический граф.

\n

Механизм границы

\n

Пакет может содержать больше, чем обещает. Внутри formatter-а допустимы cache key, fallback locale и адаптер к библиотеке дат. Consumer должен видеть root specifier и небольшой набор имён. Если consumer импортирует внутренний файл, устройство каталогов превращается в публичный контракт. Если utility импортирует доменный enum, она получает чужое правило принятия решений.

\n

Type-only import не отменяет границу. Такой импорт может исчезнуть из JavaScript, но останется в исходном коде и в декларациях. Formatter всё равно знает язык billing. Поэтому проверка «в bundle нет billing» отвечает не на тот вопрос. Нужно спросить: может ли владелец billing изменить статус, не меняя контракт общей утилиты?

\n
Три уровня защиты границы
УровеньПроверяетНе доказываетДействие
Public API recordразрешённые specifier, имена, входы, выходы и ownerреальное разрешение модулейзафиксировать смысл договора
package.json exportsдоступные package entry pointsотсутствие абсолютных обходов и доменную политикусузить surface для поддерживаемого runtime
TypeScript resolutionсогласованное разрешение imports/exports и формата модулейправо utility знать чужую модельсинхронизировать compiler и runtime
ESLint restrictionназванные статические import routesdynamic import и полный графзакодировать узкий запрет с альтернативой
\n
\"Схема
Учебная схема: root API принимает примитивные данные, а доменный тип и внутренний subpath находятся за границей. Иллюстрация не описывает настоящий registry или production-пакет.
\n

Пример: вернуть смысл владельцу домена

\n

Рассмотрим синтетический пакет @synthetic/platform-formatting. Он форматирует деньги и даты. Billing хочет показывать особый текст для просроченного счёта. Плохой путь передаёт в formatter весь invoice или импортирует InvoiceStatus. Тогда форматирование решает бизнес-вопрос. Новый статус становится изменением shared package.

\n

Безопаснее сначала получить display model на стороне billing. Formatter принимает только данные, которые ему нужны для отображения. Пример учебный: он не доказывает работу настоящего приложения и не является рекомендацией менять конкретный репозиторий.

\n
// Синтетический пример. Billing владеет интерпретацией статуса.\nconst display = {\n  statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'Открыт',\n  amountMinor: invoice.amountMinor,\n  currencyCode: invoice.currencyCode,\n};\n\n// Общая утилита получает только форматируемые значения.\nconst amountLabel = formatMoney({\n  amountMinor: display.amountMinor,\n  currencyCode: display.currencyCode,\n  locale: 'ru-RU',\n});
\n

У consumer-а остаётся один публичный маршрут: @synthetic/platform-formatting. В record можно записать formatMoney и formatIsoDate как public names, а internal/* и доменные импорты — как запрещённые направления. Если функция действительно нужна нескольким пакетам, её добавляют в root API с owner, входами, выходами и планом совместимости. Deep import не становится API только потому, что он уже используется.

\n

Как связать договор и инструменты

\n

Поле exports в package.json помогает объявить entry points. Resolver видит перечисленные subpath, а не случайные файлы каталога. Это полезная граница package surface. Но абсолютный путь к файлу может обойти такую инкапсуляцию. Значит, exports не является security boundary и не доказывает отсутствие плохих зависимостей.

\n

TypeScript в режимах node16 и nodenext учитывает модель Node и package maps. Это уменьшает расхождение между проверкой типов и запуском. Но компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility. Смысловой запрет остаётся задачей контракта и политики.

\n

ESLint можно настроить на конкретные маршруты. Запретите consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Сообщение должно предлагать root API или adapter. Правило должно быть узким: общий запрет «не импортировать домены» может заблокировать законный интеграционный слой.

\n
/* Учебная политика ESLint, не готовая конфигурация проекта. */\n'no-restricted-imports': ['error', {\n  patterns: [{\n    group: ['@synthetic/platform-formatting/internal/*'],\n    message: 'Используйте root API пакета.',\n  }],\n}]
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Utility импортирует доменный типсмысл статуса не принадлежит utility, но public API не описанкто меняет enum и кто меняет форматированиеперенести интерпретацию в domain owner, передать primitive/display data
Consumer импортирует /internalфайловое устройство приняли за контрактесть ли стабильная семантика и root exportубрать deep import или оформить отдельный public export
Lint rule просит исключениеправило появилось раньше архитектурного решенияназваны ли адресат, route и легальная альтернативасначала записать boundary record, затем настроить guard
Сборка чистая, но coupling растётпроверяется emitted code, а не исходный import graphнайти type-only, re-export и dynamic edges отдельнодобавить статические проверки и ручной review исключений
\n

Порядок действий

\n
  1. Выберите один пакет и назовите его роль. Не начинайте с общей папки shared.
  2. Запишите root specifier, public names, входы, выходы, owner и допустимых consumers.
  3. Отметьте внутренние subpath и доменные факты, которые пакет не должен интерпретировать.
  4. Проверьте реальные import routes: обычный import, re-export, type-only import и dynamic import.
  5. Настройте exports и compiler resolution только в поддерживаемой toolchain.
  6. Добавьте узкие ESLint restrictions с понятной альтернативой.
  7. Разберите каждое исключение отдельно. Для adapter укажите владельца и срок удаления.
  8. Проверьте public API тестом поведения и повторите поиск запрещённых маршрутов.
\n

Ограничения и критерий готовности

\n

Схема не делает пакеты независимыми автоматически. Adapter-ы, generated clients, plugin systems и framework entry points могут законно пересекать слои. Для них нужен явный маршрут и owner. Статический lint не описывает runtime registry и не ловит все вызовы import(). exports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript resolution должен совпадать с тем, что реально запускает приложение.

\n

Не выдавайте учебный пример за аудит. В этой статье нет утверждения о production-результатах, размере bundle, CI или состоянии конкретного репозитория. Проверять нужно область, toolchain и импортный граф, а затем отдельно проверять поведение root API.

\n

Граница готова, если любой новый import можно классифицировать без чтения всего пакета: он входит в public API, нарушает названное правило или проходит через документированный adapter. Для выбранного пакета должны быть записаны owner и root API; команда должна получить диагностическое сообщение на запрещённый static import; тест public API должен пройти; поиск по исходникам не должен находить неразрешённые deep imports. Это проверяемый критерий, а не обещание абсолютной изоляции.

\n

Проверяемые источники

\n" + "title": "Границы пакетов: как отделить public API от внутренностей", + "excerpt": "Публичный API пакета — это договор о маршрутах импорта, данных и владельце смысла. Разбираем, как обнаружить утечку домена, ограничить deep import и не перепутать возможности Node.js, TypeScript и ESLint.", + "contentHtml": "

Сбой границы редко начинается с красной сборки. Сначала formatter получает InvoiceStatus, чтобы вывести подпись рядом с суммой. Затем другой consumer импортирует cache по пути platform-formatting/internal/cache, потому что так короче. TypeScript не возражает, autocomplete подсказывает путь, тесты проходят. Цена появляется при следующем изменении: новый статус требует выпуска formatter-а, а переименование cache заставляет искать неизвестных потребителей.

\n

У этой ситуации две разные причины. Доменный тип переносит в общую утилиту смысл, которым владеет billing. Deep import превращает расположение файла в обещание для consumer-а. Лечить их одним запретом нельзя. Сначала нужно описать public API, затем поставить подходящие технические проверки и явно оставить места, где связь допустима.

\n

Сначала договор, потом инструменты

\n

Пакет может содержать больше, чем он обещает. Внутри formatter-а могут жить cache key, fallback локали и адаптер к библиотеке дат. Consumer должен знать root specifier, публичные имена, формат входа и результата. Если он импортирует внутренний файл, любое переименование реализации становится изменением чужого контракта.

\n

Граница отвечает не только на вопрос «откуда импортировать». Она фиксирует владельца смысла. Общая функция может превратить число в строку валюты, но не должна решать, что статус счёта означает «просрочен». Это решение принадлежит billing. Передавать нужно примитивы или готовую модель отображения, а не весь объект домена.

\n
Четыре слоя, которые нельзя подменять друг другом
СлойЧто проверяетЧего не доказываетПрактический вопрос
Boundary recordroot, public names, входы, выходы и ownerчто runtime действительно разрешает только эти маршрутыКто принимает изменение surface?
package.json exportsдоступные package entry points и subpathsдоменную корректность и абсолютные обходыКакой bare specifier разрешён?
TypeScript resolutionсопоставление module resolution с runtime или bundlerправо utility знать чужую бизнес-модельОдинаково ли разрешаются типы и запуск?
ESLint restrictionназванные статические import routesdynamic import, generated code и полный графКакой запрет должен сработать на diff?
\n
\"Схема
Схема показывает учебное правило направления: consumer использует root API, а доменный тип и внутренний cache не становятся частью обещанного surface. Названия synthetic-пакета не описывают конкретный registry.
\n

Две утечки, два решения

\n

Утечка домена видна по ответу на вопрос «кто меняет этот факт?». Если набор значений InvoiceStatus меняет billing-команда, formatter не должен импортировать enum даже через import type. Type-only import может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Значит, shared-пакет всё равно связан со словарём billing.

\n

Deep import имеет другую цену. Consumer зависит от имени файла, структуры каталогов и поведения private helper-а. Исправление начинается с выяснения потребности: нужна публичная операция или случайно найденная деталь? Если нужна операция, её оформляют именованным export с owner, входами, результатом и правилами совместимости. Если нужна деталь, consumer должен исчезнуть, а cache остаться у владельца.

\n
Диагностика по наблюдаемому симптому
СимптомПричинаПроверкаДействие
Utility импортирует InvoiceStatusтехнический слой интерпретирует доменкто меняет enum и labelвернуть mapping в billing, передать display data
Consumer импортирует /internal/*каталог приняли за APIесть ли стабильная операция за root exportдобавить reviewed export или убрать зависимость
Появился type-only importпроверяют bundle вместо исходной зависимостинайти import type, re-export и declarationоценить смысловую связь, а не только emitted code
Lint просит исключениеправило появилось раньше решения о границеназваны ли route, owner и альтернативаоформить adapter с ограниченным сроком
\n

Воспроизводимый пример: mapping до formatter

\n

Ниже синтетический пример запускается в Node.js без зависимостей. Billing выбирает подпись статуса, а formatter получает только минимальные данные. Команда с Node.js 12 и новее может скопировать команду целиком; --input-type=module явно задаёт режим для кода из standard input.

\n
node --input-type=module <<'EOF'\nconst invoice = { status: 'overdue', amountMinor: 12345, currencyCode: 'RUB' };\n\nconst view = {\n  amountMinor: invoice.amountMinor,\n  currencyCode: invoice.currencyCode,\n  statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'К оплате',\n};\n\nconst formatMoney = ({ amountMinor, currencyCode, locale }) =>\n  new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode })\n    .format(amountMinor / 100);\n\nconsole.log(formatMoney({ ...view, locale: 'ru-RU' }) + ' — ' + view.statusLabel);\nEOF
\n

Ожидаемый результат — строка вида 123,45 ₽ — Просрочен. В разных версиях ICU и окружениях пробел перед символом валюты может отличаться, поэтому проверяйте смысл результата, а не сравнивайте байты вывода. Важнее другое: появление статуса disputed меняет mapping в billing, но не требует добавлять этот статус в formatter.

\n

Как задать public surface

\n

Для условного пакета @example/platform-formatting достаточно короткой записи: root — @example/platform-formatting, public names — formatMoney и formatDate, owner — команда форматирования, запрещённый consumer route — @example/platform-formatting/internal/*. Отдельно запишите запретное исходящее направление: utility не импортирует @example/billing-domain/*.

\n

Эта запись нужна до настройки lint. Она позволяет отличить нарушение от законного adapter-а. Adapter должен иметь собственное имя и owner, принимать узкую модель и иметь условие удаления, например отсутствие потребителей старого specifier в поиске по исходникам. Сам факт, что два пакета используют одинаковый helper, не делает helper общей абстракцией.

\n
{\n  "name": "@example/platform-formatting",\n  "exports": {\n    ".": "./dist/index.js",\n    "./format-date": "./dist/format-date.js"\n  }\n}
\n

Явный exports объявляет entry points пакета. Если ./internal/cache не перечислен, обычный импорт по имени пакета в поддерживаемом Node.js завершается ERR_PACKAGE_PATH_NOT_EXPORTED. Добавление exports в существующий пакет может стать breaking change, если consumers уже использовали неявные subpaths. Перед включением нужно перечислить прежние поддерживаемые точки входа и выбрать план миграции.

\n

Что проверяют Node.js, TypeScript и ESLint

\n

Node.js проверяет package surface при разрешении package specifier. Поле exports умеет ограничить main entry point и named subpaths, но не является сильной изоляцией: прямой абсолютный путь к файлу может обойти эту инкапсуляцию. Поэтому exports — контракт package resolver-а, а не защита от любого доступа к файловой системе.

\n

TypeScript в режимах node16 и nodenext моделирует различия ESM и CommonJS и учитывает package maps при соответствующей конфигурации. Это помогает приблизить type-check к реальному разрешению модулей. Компилятор всё равно не знает, кому принадлежит бизнес-смысл InvoiceStatus. Смысловую границу задаёт архитектурный договор.

\n

ESLint rule no-restricted-imports подходит для статических маршрутов. Для deep imports можно задать pattern и понятное сообщение:

\n
{\n  "rules": {\n    "no-restricted-imports": ["error", {\n      "patterns": [{\n        "group": ["@example/platform-formatting/internal/*"],\n        "message": "Используйте root API пакета."\n      }]\n    }]\n  }\n}
\n

Это правило действует на static import и не обещает проверить dynamic import(). Generated files, path aliases, re-export и loader registry нужно покрыть отдельными проверками. Не расширяйте pattern до всех shared-пакетов: такой запрет может блокировать законный интеграционный слой и заставить команду добавлять бессодержательные исключения.

\n

Порядок миграции

\n
  1. Запишите точный симптом: файл, module specifier и импортируемое имя. Формула «связность выросла» недостаточна для проверки.
  2. Назовите владельца смысла каждого доменного типа. Тот, кто меняет значения и правила, должен интерпретировать их.
  3. Разделите два направления: utility → domain и consumer → internal. Для них нужны разные исправления.
  4. Зафиксируйте root, public names, входы, выходы, owner и допустимых consumers.
  5. Проверьте обычные imports, re-exports, import type, dynamic import() и generated code в своей области.
  6. Настройте exports и режим разрешения TypeScript в соответствии с реально запускаемым runtime или bundler-ом.
  7. Добавьте узкий ESLint pattern и отрицательный тест, который ломается на запрещённом static import.
  8. Перенесите domain mapping обратно владельцу, а для legacy consumer-а создайте минимальный adapter с условием удаления.
  9. Повторите поиск запрещённых маршрутов и проверьте поведение public API после сборки.
\n

Ограничения применимости и критерий готовности

\n

Эта схема не делает пакеты независимыми автоматически. Generated clients, plugin systems, framework entry points и интеграционные adapters могут пересекать обычные слои. Для каждого исключения нужны назначенный owner, документированный маршрут и проверка, которая действительно охватывает этот способ загрузки.

\n

Поведение exports зависит от версии Node.js, package manager и bundler-а. TypeScript должен использовать режим, совместимый с запуском; иначе type-check и runtime могут разрешить разные пути. ESLint не строит полный граф зависимостей и не ловит dynamic import. Абсолютный путь может обойти package encapsulation. Поэтому корректный вывод звучит узко: «названный static route запрещён в заданном scope», а не «весь монорепозиторий не содержит утечек».

\n

Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, юридических формулировок, локализации и финансовой точности продукта. Реальный billing-контракт должен иметь собственные типы и тесты. При вводе exports в существующий пакет отдельно проверьте обратную совместимость прежних entry points.

\n

Граница готова, когда у каждого спорного import-а есть четыре ответа: кто владеет смыслом, какой route разрешён, чем запрещён обход и как проверяется поведение. Consumer импортирует root API, type-check и static guard проходят в поддерживаемой конфигурации, а временный adapter имеет условие удаления. Это проверяемый уровень контроля, а не обещание абсолютной изоляции.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/138.json b/editorial/agent-rewrites/138.json index 18b6a20..b4fbe30 100644 --- a/editorial/agent-rewrites/138.json +++ b/editorial/agent-rewrites/138.json @@ -1,7 +1,7 @@ { "index": 138, "slug": "editorial-2024-03-practice-package-boundaries", - "title": "Границы пакетов: как не превратить общую утилиту в скрытую платформу", - "excerpt": "Общая утилита становится дорогой в сопровождении, когда принимает доменные типы и открывает внутренние файлы. Разбираем короткий public API, запретные направления, проверку и безопасный путь исправления.", - "contentHtml": "

Проблема часто начинается с безобидного изменения. Formatter денег уже используется в billing и orders. В него добавляют условие для InvoiceStatus, чтобы рядом с суммой вывести «Просрочен». Другой consumer импортирует внутренний cache по пути platform-formatting/internal/cache. Сборка проходит. Симптом появляется позже: изменение enum требует правки общей утилиты, очистка cache ломает consumer, а reviewer не может отличить обещанный API от случайного файла.

Цена ошибки — не одна лишняя зависимость. Доменная модель проникает в пакет, который считали нейтральным. Владелец billing начинает влиять на форматтер, владелец форматтера — на orders. Любой рефакторинг проходит через большее число команд. Ошибку труднее локализовать. Откат затрагивает код, который изначально не должен был знать друг о друге.

Тезис: граница пакета начинается с короткого договора, а не с папки и не с конфигурации линтера. Договор называет root specifier, публичные имена, входы, выходы и запрещённые направления. Инструменты затем проверяют отдельные части договора. Они не принимают архитектурное решение вместо владельца пакета.

Что именно считать границей

Пакет содержит больше кода, чем обещает. Внутри могут лежать cache, fallback для locale, адаптеры и тестовые helpers. Consumer должен видеть только root entry point и имена, которые команда готова поддерживать. Любой другой импорт превращает текущую структуру файлов в неявный контракт.

Доменная граница проходит по смыслу данных. amountMinor, currencyCode и locale описывают вход для форматирования. InvoiceStatus, лимит возврата и правило просрочки описывают billing. Если formatter принимает Invoice, он получает право интерпретировать чужую модель. Если formatter импортирует InvoiceStatus даже только как тип, зависимость остаётся: исходный код и декларации начинают отражать billing-словарь.

Правильный consumer сначала принимает решение у себя, затем передаёт утилите нейтральные данные. Billing может превратить статус в свою подпись, а formatter — отформатировать сумму. Так изменение статуса остаётся у владельца billing. Общий пакет меняет только правила представления чисел, валюты и даты.

Симптом → причина → проверка → действие

Учебная карта диагностики package boundary
СимптомПричинаПроверкаДействие
Utility импортирует InvoiceStatusДоменная интерпретация вошла в общий пакетПосмотреть imported name и владельца типаВернуть выбор статуса в billing и передать primitive values
Consumer импортирует /internal/*Public API не назван или оказался слишком малСверить specifier с API recordУбрать deep import либо открыть отдельный reviewed export
Новый export добавляют «на всякий случай»Поверхность пакета растёт без владельцаПроверить consumer, семантику и срок поддержкиОставить только нужное имя и зафиксировать owner
Lint rule разрешает всё через исключениеИнструмент скрывает неясное архитектурное решениеНазвать точный allowed route и причину исключенияСузить правило или создать именованный adapter
Надеются на один механизмexports, TypeScript и lint смешали в одно обещаниеПроверить область действия каждого слояРазделить package surface, module resolution и policy

Пример: от доменного типа к нейтральному API

Ниже приведён учебный пример. Имена billing и platform-formatting вымышлены. Код показывает форму границы, а не состояние конкретного проекта.

// Учебный пример: billing владеет смыслом статуса.\nconst displayData = {\n  statusLabel: invoice.status === \"overdue\" ? \"Просрочен\" : \"Открыт\",\n  amountMinor: invoice.amountMinor,\n  currencyCode: invoice.currencyCode,\n};\n\n// Общая функция получает только данные для форматирования.\nconst amountLabel = formatMoney({\n  amountMinor: displayData.amountMinor,\n  currencyCode: displayData.currencyCode,\n  locale: \"ru-RU\",\n});

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

Три разных механизма

Node.js package.json с полем exports описывает доступные entry points при обычном импорте пакета. Это полезно для surface и совместимости. Неэкспортированный subpath перестаёт быть обычной частью package API. Но exports не является защитой от любого прямого обращения к файлу. Он также не знает, что InvoiceStatus относится к billing и потому не должен попадать в formatter.

TypeScript в режимах node16 и nodenext учитывает exports, imports, self-reference и различия ESM/CJS. Compiler помогает согласовать типы с module resolution. Он не решает вопрос владения бизнес-смыслом. Корректный type-check не делает доменную зависимость хорошей.

ESLint no-restricted-imports подходит для названных статических маршрутов. Можно запретить consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Это узкая проверка синтаксиса. Она не строит полный граф dynamic import, generated code и runtime plugin loading. В policy нужно обещать только то, что выбранное правило действительно видит.

\"Учебный
Иллюстрация показывает направление зависимостей в учебной модели. Она не является скриншотом и не доказывает граф какого-либо production-приложения.

Как поставить границу в существующем коде

Не начинайте с переезда всех файлов. Сначала остановите расширение поверхности. Назовите один root specifier и список публичных имён. Отдельно запишите forbidden routes: consumer не ходит в internal и src, а formatter не импортирует billing и account domains. Исключение для adapter-а оформляйте отдельным пакетом или явно названным слоем. Иначе исключение быстро станет новым правилом.

Затем возьмите один реальный edge. Если utility импортирует доменный тип, перенесите интерпретацию к owner-у домена. Если consumer использует cache, решите, кому принадлежит lifetime и invalidation. Иногда cache должен остаться деталью utility. Иногда несколько consumers действительно нуждаются в стабильном сервисе. Во втором случае публикуйте осмысленный API с входами, выходом и правилами изменения. Не экспортируйте внутренний объект только потому, что он уже существует.

Порядок действий

  1. Зафиксируйте точный module specifier, imported name и слой, где находится зависимость.
  2. Назначьте владельца каждого типа и функции. Если владелец не найден, не расширяйте API.
  3. Составьте короткий API record: root specifier, public names, входы, выходы и forbidden routes.
  4. Разделите domain decision и formatting. Перенесите интерпретацию модели обратно к её owner-у.
  5. Удалите deep import. Если consumer не может работать через root API, проведите отдельный review нового export-а или adapter-а.
  6. Настройте exports, TypeScript resolution или ESLint только для тех правил, которые уже согласованы.
  7. Проверьте положительный и отрицательный пути: допустимый root import проходит, доменный import и internal subpath получают понятный отказ.

Проверка на учебной модели

Для локальной проверки формы договора можно использовать три заранее заданных случая: чистый root import, utility с доменным импортом и consumer с deep import. Такой тест полезен, если он явно называет свои границы. Он проверяет классификацию записанных примеров. Он не читает репозиторий, не строит настоящий import graph и не доказывает состояние CI.

const boundary = {\n  publicSpecifier: \"@synthetic/platform-formatting\",\n  publicNames: [\"formatMoney\", \"formatIsoDate\"],\n  forbiddenConsumerRoutes: [\"@synthetic/platform-formatting/internal/*\"],\n  forbiddenUtilityTargets: [\"@synthetic/billing-domain/*\"],\n};\n\n// Проверяемый учебный результат:\n// clean root import       - compliant\n// utility -> billing      - violated\n// consumer -> internal    - violated

Если такой пример называют проверкой проекта, он вводит в заблуждение. Для реального edge нужны согласованная область чтения, выбранный resolver, учёт aliases и generated layers, затем отдельная проверка toolchain. Учебная модель не заменяет эти шаги.

Ограничения

Эта схема не делает пакеты независимыми автоматически. Она не измеряет размер bundle, не доказывает отсутствие циклов, не проверяет семантическую совместимость всех версий и не описывает dynamic loading. В legacy-коде может потребоваться временный adapter. У adapter-а должны быть владелец, разрешённый маршрут и дата удаления исключения.

Глобальный запрет тоже опасен. Framework entry point, generated client и plugin adapter могут законно пересекать слои. Важно назвать роль такого пакета. Запрещайте не слово domain, а конкретное направление для конкретного owner-а. Иначе команда начнёт отключать правило вместо исправления зависимости.

Критерий готовности

Работа готова, когда для одного выбранного package можно ответить на пять вопросов без поиска по всему репозиторию: кто owner; какой root specifier обещан; какие имена и входы публичны; какие направления запрещены; чем проверяется каждый запрет. Положительный тест импортирует только root API. Отрицательные тесты показывают отказ для domain leak и deep import. Проверка явно указывает, какие пути она не покрывает.

После этого изменение InvoiceStatus не требует знания внутренностей formatter-а, а изменение cache не заставляет искать случайных consumers. Граница не запрещает развитие пакета. Она делает цену нового знания видимой до того, как оно станет общей платформой.

Проверяемые источники

" + "title": "Границы пакетов: как закрыть deep import и не смешать домен с утилитой", + "excerpt": "Общий пакет начинает дорожать в сопровождении, когда его внутренние файлы становятся API, а доменные типы проникают в нейтральную утилиту. Разбираем контракт root entry point, проверку через exports, TypeScript и ESLint и безопасную миграцию существующих импортов.", + "contentHtml": "

Проблема проявляется не в момент создания пакета. Сначала formatter денег используют в billing и orders через один импорт. Потом в formatter добавляют условие для InvoiceStatus, а другой consumer берёт cache напрямую из @example/platform-formatting/src/internal/cache.js. Сборка ещё проходит, но любой рефакторинг внутреннего файла превращается в поиск неизвестных владельцев.

Цена ошибки состоит из двух разных зависимостей. Доменная модель попадает в общий пакет и заставляет его знать правила billing. Внутренний файл становится неявным API, хотя команда не обещала его сохранять. В результате изменение enum, очистка cache или переезд src затрагивают больше consumers, чем видно по публичному описанию пакета.

Решение начинается с короткого договора: какой module specifier разрешён, какие имена экспортируются, какие значения принимает функция и какие направления запрещены. Затем каждый инструмент проверяет свою часть договора. exports закрывает package subpaths при обычном разрешении Node.js, TypeScript согласует типы с resolver-ом, а ESLint ловит известные статические маршруты. Ни один из этих механизмов сам по себе не определяет, кому принадлежит бизнес-смысл.

Симптом и граница ответственности

Начните с конкретного edge — направленной зависимости между двумя пакетами. Зафиксируйте импортирующий файл, module specifier, imported name и владельца типа. Если platform-formatting импортирует InvoiceStatus, это не «технический тип без последствий». Даже type-only зависимость связывает словарь общего пакета с billing и меняет его публичный контракт.

Решение о том, что счёт просрочен, принадлежит billing. Formatter должен получить уже выбранные данные: сумму, код валюты и locale. Он не должен импортировать Invoice, выбирать подпись статуса или знать, что один из consumers показывает возвраты. Такая граница переносит изменение туда, где живёт правило, и оставляет общей утилите одну причину для изменения — представление значения.

Диагностическая карта для одного package edge
НаблюдениеЧто это означаетПроверкаИсправление
Утилита импортирует InvoiceStatusОбщий слой интерпретирует доменную модельНайти imported name и владельца типаВыбрать статус в billing и передать нейтральные значения
Consumer импортирует /src/ или /internal/Структура файлов стала неявным APIСопоставить specifier с package contractПерейти на root import или отдельно согласовать новый export
В exports добавляют весь каталогПроверка заменена широким исключениемПосчитать реальные consumers и публичные именаОписать точные subpaths либо оставить только .
ESLint-правило молчитМаршрут не попал в область его анализа или импорт динамическийПроверить resolver, pattern и форму импортаСузить утверждение либо добавить отдельную проверку графа
После добавления exports ломается старый consumerРанее поддерживался неописанный entry pointСверить историю импортов и release notes пакетаСначала экспортировать совместимый путь, затем объявить миграцию

Контракт public API

Для небольшого shared-пакета достаточно одной записи, которую можно проверить в review. В ней должны быть root specifier, публичные имена, формы входа и выхода, владелец, разрешённые зависимости и запретные маршруты. Например: @example/platform-formatting экспортирует formatMoney; функция принимает amount, currencyCode и locale; пакет не импортирует @example/billing-domain/*; consumers не импортируют его src/*.

Не путайте публичное имя с файлом, в котором оно сейчас лежит. Внутри можно поменять format-money.js на несколько модулей, если root export и поведение функции остаются совместимыми. И наоборот: экспорт всего каталога делает каждое имя частью ожиданий consumers, даже если оно появилось как временный helper.

Учебный граф: orders и billing импортируют root API platform-formatting, пакет форматирования использует runtime, а запрещённая пунктирная стрелка ведёт от formatter к billing domain.
В учебной модели домен принимает решение и использует formatter, а formatter не тянет обратно доменный тип. Граф объясняет направление зависимости; он не является снимком конкретного production-репозитория.

Что даёт package.json.exports

В Node.js поле exports перечисляет entry points, доступные при обычном импорте пакета. Если оставить только точку ., попытка импортировать @example/platform-formatting/src/internal/cache.js должна завершиться ошибкой ERR_PACKAGE_PATH_NOT_EXPORTED. Это полезная машинная граница: переезд cache внутри пакета не обязан сохранять старый путь.

{
  "name": "@example/platform-formatting",
  "type": "module",
  "exports": {
    ".": "./src/index.js"
  }
}

Файл src/index.js публикует только согласованные функции:

export { formatMoney } from "./format-money.js";

После установки локального пакета положительный и отрицательный smoke-check можно повторить командами:

node --input-type=module -e 'import("@example/platform-formatting").then(({ formatMoney }) => console.log(formatMoney({ amount: 1234.5, currencyCode: "RUB", locale: "ru-RU" })))'
node --input-type=module -e 'import("@example/platform-formatting/src/internal/cache.js").then(() => process.exit(1), error => { if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") process.exit(1); console.log(error.code); })'

Первый вызов проверяет, что root API разрешается и возвращает функцию. Второй считает успехом именно ожидаемый отказ. Точный пробел или символ валюты в первой строке зависит от реализации Intl.NumberFormat и окружения; проверять следует контракт результата, а не копировать визуальную строку без оговорки.

У exports есть важные ограничения. Оно действует для package resolution, но не является защитой от прямого абсолютного доступа к файлу на диске. Оно также не исправляет consumer, который уже использует другой resolver или alias сборщика. Добавление поля в существующий пакет может быть breaking change: ранее неописанные entry points перестанут разрешаться. Перед включением нужно найти такие импорты и решить, какие из них действительно поддерживаются.

Роль TypeScript и ESLint

TypeScript 4.7 добавил режимы node16 и nodenext. В этих режимах compiler учитывает модульную модель Node.js и поля exports/imports при разрешении пакетов. Это помогает получить одинаковую границу для исходников и деклараций, но не заменяет проверку runtime. Другой режим, path alias или отдельная конфигурация bundler-а могут дать отличающийся результат.

{
  "compilerOptions": {
    "module": "node16",
    "moduleResolution": "node16",
    "strict": true
  }
}

ESLint подходит для статических запретов, которые можно сформулировать как маршруты. В legacy-конфигурации правило можно применить к двум сторонам границы:

{
  "overrides": [
    {
      "files": ["packages/platform-formatting/src/**/*.js"],
      "rules": {
        "no-restricted-imports": ["error", {
          "patterns": [{
            "group": ["@example/billing-domain/*"],
            "message": "formatter не импортирует billing domain"
          }]
        }]
      }
    },
    {
      "files": ["packages/orders/**/*.js"],
      "rules": {
        "no-restricted-imports": ["error", {
          "patterns": [{
            "group": ["@example/platform-formatting/src/*"],
            "message": "используйте root API formatter"
          }]
        }]
      }
    }
  ]
}

Здесь paths нужен для точного имени, а patterns — для группы путей с wildcard. Правило не строит полный runtime-граф: dynamic import(), загрузчик плагинов, generated code и обход через абсолютный путь требуют отдельной проверки. Type-only import тоже остаётся архитектурной связью. Разрешайте его исключением только тогда, когда это часть договора, а не способ спрятать доменную зависимость.

Как мигрировать существующий пакет

Сразу закрыть все старые пути в большом репозитории рискованно. Сначала составьте список imports по тексту и по инструменту, которым действительно собирается проект. Отдельно отметьте production-код, тесты, storybook, скрипты и generated files. У каждого найденного пути должен появиться статус: поддерживаемый root API, кандидат на отдельный export, временный adapter или ошибка.

Если в пакете уже есть consumers, не удаляйте их маршрут только потому, что он выглядит некрасиво. Для обратной совместимости можно временно экспортировать точно известный subpath, предупредить consumers и удалить его в следующем совместимом процессе. В новом пакете лучше начать с минимального списка. Публикация целого src редко является нейтральным компромиссом: она закрепляет внутреннюю структуру на будущее.

Доменную зависимость мигрируйте отдельным изменением. Сначала добавьте в consumer функцию, которая преобразует InvoiceStatus в собственную подпись. Затем передайте в formatter только нейтральные данные. После проверки consumers удалите импорт домена из utility. Такой порядок позволяет отличить изменение ответственности от изменения формата и легче откатить неудачный шаг.

Порядок воспроизводимой проверки

  1. Запишите один проблемный edge: файл, specifier, imported name, направление и владельца.
  2. Найдите фактические consumers, включая тестовые и инструментальные конфигурации. Не считайте совпадение в документации импортом без проверки.
  3. Сформулируйте API record с root entry point, публичными именами, входами, выходами и forbidden routes.
  4. Перенесите решение о доменном состоянии к его владельцу и оставьте utility нейтральные значения.
  5. Добавьте минимальный exports и положительный тест root import.
  6. Добавьте отрицательный тест deep import и проверьте ожидаемый код ошибки в том же resolver-е, который использует приложение.
  7. Настройте TypeScript и ESLint на согласованные маршруты; отдельно перечислите dynamic loading, aliases и generated layers, которые эти проверки не покрывают.
  8. Проверьте сборку и тесты consumers, затем удалите временный adapter только после того, как список старых импортов стал пустым.

Полезно сохранить два отрицательных теста рядом с контрактом: utility не может импортировать billing domain, consumer не может импортировать internal subpath. Положительный тест тоже обязателен. Запрет без рабочего root API только перенаправляет команду к новому обходному пути.

Ограничения и критерий готовности

Эта схема не доказывает отсутствие циклов, не проверяет семантическую совместимость версий и не измеряет размер bundle. Она не видит автоматически каждый alias, runtime plugin loader или абсолютный путь. Если сборщик не повторяет правила Node.js, результат smoke-check нужно получить именно через его resolver. Если пакет поддерживает CommonJS и ESM, проверяйте обе точки входа и не смешивайте их contract без явного решения.

Глобальный запрет на слово domain тоже не является архитектурой. Иногда отдельный adapter действительно должен пересекать слои. Зафиксируйте его владельца, разрешённый маршрут, причину и условие удаления. Временное исключение безопаснее, когда оно названо и наблюдаемо; молчаливый deep import просто переносит стоимость на следующий рефакторинг.

Граница готова, когда команда может ответить на пять вопросов без чтения внутреннего каталога: кто владелец пакета; какой root specifier обещан; какие имена и данные публичны; какие направления запрещены; каким инструментом проверяется каждый запрет. В репозитории есть рабочий root import, отрицательный тест для deep import и список известных исключений. Тогда изменение InvoiceStatus остаётся в billing, а изменение cache не требует обзванивать случайных consumers.

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/139.json b/editorial/agent-rewrites/139.json index 1753ff0..fa1c811 100644 --- a/editorial/agent-rewrites/139.json +++ b/editorial/agent-rewrites/139.json @@ -3,5 +3,5 @@ "slug": "editorial-2024-02-field-modular-monolith", "title": "Модульный монолит под ревью: как остановить протечку границ", "excerpt": "Три похожих импорта могут незаметно связать каталог, checkout и оплату. Разбираем границы на учебном примере, проверяем API и направление зависимостей, а затем выбираем обратимое исправление.", - "contentHtml": "

В pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает приватный formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется. Тест на один сценарий проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду. Цена ошибки — не эстетика архитектуры. Она выражается в связанных релизах, длинном ревью и скрытом риске сломать соседний сценарий.

\n

Модульный монолит не запрещает модулям общаться. Он делает эту связь проверяемой. Для каждой стрелки нужно назвать consumer, owner, публичную поверхность и направление. Если хотя бы одно поле неизвестно, импорт ещё не является понятным контрактом. Такой разбор не доказывает корректность всего приложения. Он отвечает на более узкий вопрос: кто имеет право вызвать кого и что произойдёт с границей после следующего изменения.

\n

Ниже приведён учебный пример с фиксированными именами catalog, checkout, payments и notifications. Он не описывает реальный репозиторий, метрики или результат CI. Его задача — показать форму рассуждения. В настоящем проекте каждую строку из примера нужно подтвердить точечным анализом исходников и отдельными тестами.

\n

Что именно ломается

\n

Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.

\n

Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.

\n

Тезис и механизм

\n

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

\n

Проверка начинается не с названия класса, а с конкретной ссылки. Запишите её как source → target.surface. Затем ответьте на четыре вопроса: какой сценарий обслуживает вызов, кто меняет состояние, можно ли получить результат через опубликованный контракт и не создаёт ли стрелка цикл. Ответ «так принято» не заменяет ни одного из них.

\n
record BoundaryLink(\n    String source,\n    String target,\n    String surface,\n    String scenario\n) {}\n\nBoundaryLink link = new BoundaryLink(\n    \"checkout\", \"catalog\", \"api\", \"show product card\"\n);\n\n// Проверяем отдельно:\n// 1. surface опубликована владельцем;\n// 2. source -> target разрешено картой;\n// 3. новая ссылка не замыкает цикл;\n// 4. scenario не переносит чужой инвариант.\n
\n

Код выше — учебная запись, а не готовая библиотека. В реальном Java-проекте вместо строки surface понадобятся пакеты, named interface или другой явно поддерживаемый контракт. Важно сохранить сам порядок проверки: сначала смысл вызова, затем поверхность, потом направление и цикл.

\n

Три импорта под микроскопом

\n

Допустимая поверхность. checkout → catalog.api оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.

\n

Протечка во внутренность. checkout → catalog.internal обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.

\n

Обратная зависимость. payments → checkout.api не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.

\n
СимптомПричинаПроверкаДействие
Новый вызов проходит компиляцию, но его цель неяснаНет записи о сценарии и владельцеВыписать source, target.surface и инвариантОставить ссылку только после явного контракта
Consumer импортирует internalПубличная поверхность не покрывает потребностьСравнить импорт с опубликованными пакетами или интерфейсамиВернуть операцию владельцу или добавить узкий API
Два модуля вызывают друг другаНовая обратная связь добавлена без владельца процессаПостроить граф и найти циклВыбрать orchestration или event contract
Ссылка ведёт в неизвестный модульКарта зависимостей устарела или неполнаСверить имя с исходниками и конфигурацией модулейОстановить изменение до обновления карты
После переноса тесты зелёные, но граница снова открытаПроверка была только примером, без правилаЗапустить структурную проверку и отрицательный тестЗакрепить запрет на уровне сборки или тестового набора
\n
\"Петля
Учебная схема показывает порядок boundary review. Она не является отчётом CI и не доказывает наличие такой связи в конкретном проекте.
\n

Отрицательный путь важнее зелёной ветки

\n

Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните catalog → payments.api, если для неё нет сценария и разрешённого направления. Отклоните checkout → catalog.internal, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.

\n

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

\n

Порядок действий в настоящем ревью

\n
  1. Остановите обсуждение на конкретной ссылке. Укажите файл, consumer, owner, поверхность и сценарий. Формулировка «модули связаны» слишком широка для решения.
  2. Разделите факт и гипотезу. Реальный import подтвердите исходником или разрешённым анализатором. Не выдавайте учебную карту за evidence.
  3. Проверьте поверхность. Сопоставьте вызов с опубликованным API. Если consumer использует internal, решите, где должен жить инвариант.
  4. Проверьте направление. Добавьте стрелку в карту и найдите обратный путь. При цикле назначьте orchestration или сформулируйте событие.
  5. Выберите малое обратимое изменение. Перенесите один вызов, добавьте узкий адаптер или ограничьте API. Зафиксируйте, как удалить временное решение.
  6. Закрепите правило. Добавьте структурный тест, проверку модульной схемы или иной автоматический сигнал. Отдельно проверьте запрещённый импорт.
  7. Запишите решение. Оставьте владельца, сценарий, разрешённое направление, исключение, дату пересмотра и подтверждённый источник факта.
\n

Ограничения

\n

Граф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.

\n

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

\n

Инструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.

\n

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

\n

Проверяемые источники

" + "contentHtml": "

В pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает внутренний formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется, а тест одного сценария проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду.

\n

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

\n

Ниже — воспроизводимая модель с модулями catalog, checkout, payments и notifications. Имена и связи вымышлены, поэтому это не отчёт о конкретном production-проекте. Их можно заменить своими пакетами и прогнать те же проверки на настоящем репозитории.

\n

Сначала отделите факт от предположения

\n

В ревью легко перепутать наблюдение с объяснением. Факт — checkout действительно импортирует класс из catalog.internal. Гипотеза — этот импорт появился потому, что публичный API не выражает нужный сценарий. Решение — перенести операцию к каталогу или добавить узкий публичный контракт. Каждый слой требует своей проверки.

\n
Минимальная карточка одной межмодульной связи
ПолеПримерКак подтвердить
ConsumercheckoutФайл вызывающего кода и его пакет
OwnercatalogВладелец инварианта и данных операции
Surfacecatalog.api.ProductViewПубличный пакет, интерфейс или команда
ScenarioПоказать карточку перед оформлениемТест или описание пользовательского пути
Directioncheckout → catalogКарта разрешённых зависимостей и поиск обратного пути
\n

Запись «checkout зависит от catalog» слишком широкая. Она одинаково скрывает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api уже даёт объект для ревью. Если нельзя назвать сценарий или owner, новый импорт лучше остановить до выяснения ответственности.

\n

Что именно ломается

\n

Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.

\n

Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.

\n

Граница состоит из поверхности и направления

\n

Модуль владеет смыслом операции, данными и правилами их изменения. Публичная поверхность — это обещание другому модулю, а не все классы с модификатором public. Она может быть командой, запросом, событием, портом или небольшим интерфейсом. Внутренний formatter, ORM-репозиторий и таблица не становятся API только из-за удобства импорта.

\n

В учебной карте разрешены три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Обращение checkout → catalog.internal запрещено. Обращение payments → checkout.api также требует решения: если checkout уже вызывает payments, оно замыкает цикл и оставляет неясным владельца orchestration — последовательности действий.

\n
\"Петля
Схема показывает порядок проверки связи: сначала фиксируются source и surface, затем сверяются API, направление и цикл. Это модель процесса, а не результат сканирования конкретного репозитория.
\n

Если проект использует Java Platform Module System, часть границы можно закрепить физически. Например, каталог экспортирует только API-пакет:

\n
module com.acme.catalog {\n  exports com.acme.catalog.api;\n}\n\nmodule com.acme.checkout {\n  requires com.acme.catalog;\n}\n
\n

Такой module-info.java ограничивает доступ к неэкспортированным пакетам на уровне JPMS. Но многие Java-приложения работают на classpath или используют собственное разбиение пакетов. В них понадобится архитектурный тест и правило сборки; один namespace не превращает папку в изолированный модуль.

\n

Три импорта под микроскопом

\n

Допустимая поверхность. checkout → catalog.api оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.

\n

Протечка во внутренность. checkout → catalog.internal обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.

\n

Обратная зависимость. payments → checkout.api не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.

\n
СимптомПричинаПроверкаДействие
Новый вызов проходит компиляцию, но его цель неяснаНет записи о сценарии и владельцеВыписать source, target.surface и инвариантОставить ссылку только после явного контракта
Consumer импортирует internalПубличная поверхность не покрывает потребностьСравнить импорт с опубликованными пакетами или интерфейсамиВернуть операцию владельцу или добавить узкий API
Два модуля вызывают друг другаНовая обратная связь добавлена без владельца процессаПостроить граф и найти циклВыбрать orchestration или event contract
Ссылка ведёт в неизвестный модульКарта зависимостей устарела или неполнаСверить имя с исходниками и конфигурацией модулейОстановить изменение до обновления карты
После переноса тесты зелёные, но граница снова открытаПроверка была только примером, без правилаЗапустить структурную проверку и отрицательный тестЗакрепить запрет на уровне сборки или тестового набора
\n
\"Петля
Учебная схема показывает порядок boundary review. Она не является отчётом CI и не доказывает наличие такой связи в конкретном проекте.
\n

Отрицательный путь важнее зелёной ветки

\n

Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните catalog → payments.api, если для неё нет сценария и разрешённого направления. Отклоните checkout → catalog.internal, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.

\n

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

\n

Запустите проверку на фактическом коде

\n

Сначала найдите реальные ссылки, не полагаясь на карту из памяти. Команда ниже подходит для Java-проекта, где исходники лежат в src/main/java; путь и имена пакетов нужно заменить на проектные:

\n
rg -n --glob '*.java' \\\n  '^\\s*import\\s+.*(catalog|checkout|payments)' \\\n  src/main/java\n
\n

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

\n
@AnalyzeClasses(packages = \"com.acme\")\nclass ModuleBoundaryTest {\n\n  @ArchTest\n  static final ArchRule checkout_uses_catalog_api =\n      noClasses().that().resideInAnyPackage(\"..checkout..\")\n          .should().dependOnClassesThat()\n          .resideInAnyPackage(\"..catalog.internal..\");\n\n  @ArchTest\n  static final ArchRule modules_have_no_cycles =\n      slices().matching(\"com.acme.(*)..\")\n          .should().beFreeOfCycles();\n}\n
\n

Если тест находится в Gradle-проекте с именем класса ModuleBoundaryTest, его можно запустить так:

\n
./gradlew test --tests \\\n  'com.acme.architecture.ModuleBoundaryTest'\n
\n

Этот тест проверяет зависимости классов, попавших в импорт ArchUnit. Он не видит автоматически SQL-связи, вызовы через reflection, конфигурацию контейнера, внешние очереди и бизнес-правильность транзакции. Поэтому результат нужно читать точно: «две заданные архитектурные проверки не нашли нарушение в импортированном наборе классов», а не «модуль полностью изолирован».

\n

Исправление должно уменьшать связность

\n
  1. Остановите обсуждение на конкретной ссылке. Укажите файл, consumer, owner, поверхность и сценарий. Формулировка «модули связаны» слишком широка для решения.
  2. Разделите факт и гипотезу. Реальный import подтвердите исходником или разрешённым анализатором. Не выдавайте учебную карту за evidence.
  3. Проверьте поверхность. Сопоставьте вызов с опубликованным API. Если consumer использует internal, решите, где должен жить инвариант.
  4. Проверьте направление. Добавьте стрелку в карту и найдите обратный путь. При цикле назначьте orchestration или сформулируйте событие.
  5. Выберите малое обратимое изменение. Перенесите один вызов, добавьте узкий адаптер или ограничьте API. Зафиксируйте, как удалить временное решение.
  6. Закрепите правило. Добавьте структурный тест, проверку модульной схемы или иной автоматический сигнал. Отдельно проверьте запрещённый импорт.
  7. Запишите решение. Оставьте владельца, сценарий, разрешённое направление, исключение, дату пересмотра и подтверждённый источник факта.
\n

Ограничения

\n

Граф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.

\n

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

\n

Инструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.

\n

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

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/140.json b/editorial/agent-rewrites/140.json index 8b3fcb9..1770879 100644 --- a/editorial/agent-rewrites/140.json +++ b/editorial/agent-rewrites/140.json @@ -2,6 +2,6 @@ "index": 140, "slug": "editorial-2024-02-mechanism-modular-monolith", "title": "Модульный монолит: как удержать границы до распила на сервисы", - "excerpt": "Папки по доменам не защищают код от скрытых связей. Разбираем публичную поверхность модуля, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в обычный review.", - "contentHtml": "

В монолите проблема часто начинается с маленького импорта. Код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Потом платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит. Папки по-прежнему выглядят как отдельные домены.

\n

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

\n

Тезис простой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой связи нужно назвать потребителя, владельца и поверхность доступа: consumer → owner.publicApi. Отдельно нужно перечислить разрешённые направления. Тогда правило можно обсуждать по конкретному вызову, а не по впечатлению от дерева файлов.

\n

Что именно считается границей

\n

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

\n

В учебной модели есть четыре модуля: catalog, checkout, payments и notifications. У каждого есть поверхность *.api и внутренняя часть *.internal. Разрешены только три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это пример формы правила, а не описание реальной системы.

\n
Граница читается по четырём вопросам
ВопросПример ответаЗачем он нужен
Кто вызывает?checkoutФиксирует потребителя и его сценарий
Кто владеет смыслом?catalogНазначает ответственность за изменение контракта
Через что вызывают?catalog.apiНе даёт подменить API внутренним типом
Разрешено ли направление?checkout → catalogОстанавливает случайные обратные связи
\n

Одна стрелка без поверхности слишком широка. Запись «checkout зависит от catalog» допускает и запрос карточки, и чтение репозитория, и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавлять ли узкий метод, событие или оставить операцию внутри своего модуля.

\n
Схема разрешённых направлений модульного монолита: checkout вызывает catalog.api и payments.api, payments вызывает notifications.api, обращение к catalog.internal запрещено
Учебная схема показывает направление и поверхность связи. Она не является результатом сканирования исходников и не описывает production-систему.
\n

Механизм: поверхность плюс направленный граф

\n

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

\n

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

\n
const allowed = new Set(['checkout>catalog:catalog.api', 'checkout>payments:payments.api', 'payments>notifications:notifications.api']); function check(link) { if (link.surface !== link.to + '.api') return 'non-public surface'; return allowed.has(link.from + '>' + link.to + ':' + link.surface) ? 'allowed' : 'forbidden direction'; }
\n

Код выше — учебный пример проверки заранее описанной карты. Он не читает репозиторий, не строит граф импортов и не доказывает отсутствие нарушений в приложении. В настоящем проекте анализатор должен получить фактические ссылки из подходящего инструмента языка, сопоставить их с картой и сохранить результат проверки. Если такого анализа пока нет, честный результат — «карта описана, фактические импорты не проверены».

\n

Цикл проверяют на том же графе. Если карта разрешает checkout → payments, а затем добавляет payments → checkout, две области начинают знать друг о друге. Цикл не означает, что нужно немедленно выделить микросервис. Он означает, что не назван владелец процесса. Сначала уточняют orchestration, границу инварианта и направление обмена. Иногда помогает событие. Иногда — перенос операции к владельцу. Иногда — узкий контракт без обратного вызова.

\n

Симптом → причина → проверка → действие

\n
Практическая диагностика границы
СимптомПричинаПроверкаДействие
Потребитель импортирует catalog.internalAPI не выражает нужную операцию или деталь показалась удобнееСверить surface вызова со списком API и назвать сценарийВернуть операцию владельцу либо добавить узкий контракт
Появилась обратная стрелкаНе определён владелец процесса или смешаны ответственностиПостроить граф и найти циклВыбрать orchestration, событие или перенос операции
Все импортируют commonОбщий пакет стал обходом границыПроверить, кто владеет каждым типом и кто меняет егоРазделить контракты или вернуть код владельцу
API повторяет таблицы владельцаПубличная поверхность раскрывает реализациюПроверить, может ли владелец изменить хранение без consumerСузить данные до операции, результата или события
Тест зелёный, но импорт неизвестенПроверена только модель, а не исходный кодПроверить источник фактических ссылок и дату evidenceНе выдавать модель за аудит; добавить реальный анализ
\n

Порядок внедрения

\n
  1. Выберите один болезненный стык. Возьмите изменение, которое регулярно цепляет чужую внутренность. Не начинайте с перестройки всего монолита.
  2. Запишите потребность. Назовите consumer, ожидаемый результат и модуль-владелец. Если результат нельзя описать без внутреннего класса, граница ещё не сформулирована.
  3. Опишите поверхность. Оставьте минимальный вход: команду, запрос, событие или порт. Не публикуйте namespace целиком.
  4. Добавьте направление. Запишите from → to.surface и отдельно укажите запрещённую обратную связь. У каждого исключения должен быть владелец и дата пересмотра.
  5. Проверьте существующие ссылки. Используйте анализатор языка, правила сборки или архитектурный тест, который видит реальные импорты. Учебная карта сама по себе этого не делает.
  6. Переведите один вызов. Оставьте обратимый путь, проверьте отсутствие старого потребителя и только потом удаляйте внутренний доступ.
  7. Закрепите правило. Добавьте проверку в место, где она запускается вместе с изменением кода. Документ без проверки быстро становится устным соглашением.
\n

Ограничения и отрицательный путь

\n

Граф границ не отвечает за транзакции, задержку, права доступа, размер payload, версионирование событий и качество данных. Разрешённая стрелка может вести к медленной операции. Запрещённая стрелка может стать оправданной после смены владельца. Поэтому зелёный статус архитектурной проверки не заменяет нагрузочный, security или интеграционный тест.

\n

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

\n

Java Platform Module System даёт физический пример: именованный модуль объявляет экспортируемые пакеты и зависимости. Spring Modulith показывает похожую идею для Java/Spring: API модуля отделяется от внутренних пакетов и разрешённых зависимостей. Эти механизмы нельзя перенести в любой стек без изменений. Их полезный общий принцип уже достаточен: доступ должен быть назван, ограничен и проверяем.

\n

Критерий готовности

\n

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

\n

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

\n

Проверяемые источники

\n" + "excerpt": "Папки по доменам не защищают код от скрытых связей. Разбираем публичную поверхность модуля, карту разрешённых направлений, циклы и проверку, которую можно встроить в review.", + "contentHtml": "

Ниже — учебный сценарий, а не отчёт о конкретной production-системе. В монолите код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Затем платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит, а папки по-прежнему выглядят как отдельные домены.

\n

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

\n

Практический тезис такой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой межмодульной связи нужно назвать потребителя, владельца смысла и поверхность доступа: consumer → owner.publicApi. Отдельно фиксируют разрешённые направления. Эта статья показывает модель и небольшой проверяемый fixture; он не заменяет анализатор импортов, тесты данных, нагрузку или security-проверку.

\n

Что именно считается границей

\n

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

\n

Возьмём четыре условных модуля: catalog, checkout, payments и notifications. Разрешены только checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это форма архитектурной политики, а не утверждение о структуре какого-либо проекта.

\n
Минимальная запись границы
ВопросПример ответаЧто защищаем
Кто вызывает?checkoutСценарий потребителя и его ответственность
Кто владеет смыслом?catalogПраво менять контракт и правила данных
Через что вызывают?catalog.apiЗапрет на чтение внутренней реализации
Разрешено ли направление?checkout → catalogКонтроль обратных связей и циклов
\n

Запись «checkout зависит от catalog» слишком широка: она допускает запрос карточки, чтение репозитория и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавить ли узкую операцию, событие или оставить работу внутри своего модуля.

\n
Схема учебной политики модульного монолита: checkout обращается к catalog.api и payments.api, payments обращается к notifications.api, доступ к внутренним поверхностям запрещён
Схема показывает заданные направления и границу public API. Она не построена по исходникам и не подтверждает состояние production-кода.
\n

Механизм: публичная поверхность и направленный граф

\n

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

\n

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

\n
node --input-type=module -e \"const allowed=new Set(['checkout>catalog:catalog.api','checkout>payments:payments.api','payments>notifications:notifications.api']); const links=[{from:'checkout',to:'catalog',surface:'catalog.api'},{from:'catalog',to:'checkout',surface:'checkout.api'},{from:'checkout',to:'catalog',surface:'catalog.internal'}]; const check=({from,to,surface}) => surface !== to + '.api' ? 'non-public-surface' : allowed.has(from + '>' + to + ':' + surface) ? 'allowed' : 'forbidden-direction'; console.table(links.map(link => ({...link,result:check(link)})));\"
\n

Сохраните команду во временный терминал без изменений и выполните её через Node.js 18 или новее. Ожидаемый результат для трёх строк: allowed, затем forbidden-direction, затем non-public-surface. В fixture специально нет чтения репозитория: он проверяет только заранее заданные записи. Поэтому зелёный результат доказывает корректность policy-функции для этого входа, но не отсутствие незаконных импортов в приложении.

\n

Как обнаруживать циклы и обходы

\n

Если карта разрешает checkout → payments, а затем добавляет payments → checkout, граф становится циклическим. Это не означает, что завтра нужно выделить микросервис. Это сигнал уточнить владельца процесса, границу инварианта и способ обмена. Часто операция переносится к владельцу, а результат публикуется событием; иногда нужен узкий порт без обратного вызова.

\n

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

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Импортируется catalog.internalПубличный вход не выражает нужную операцию либо внутренняя деталь удобнееСверить фактический импорт с картой APIВернуть операцию владельцу или добавить узкий контракт
Появилась обратная стрелкаНе определён владелец процессаПостроить граф и найти циклВыбрать orchestration, событие или перенос операции
Все модули импортируют commonОбщий пакет скрывает владение типамиНазвать владельца каждого типа и его измененийРазделить контракты или вернуть тип владельцу
API повторяет таблицу владельцаПубличная поверхность раскрывает хранениеПроверить, можно ли сменить storage без consumerСузить результат до операции, DTO или события
Архитектурный тест зелёный, а импорт неизвестенПроверена только модель в памятиСопоставить источник фактических ссылок с policyНе называть fixture аудитом; подключить реальный анализ
\n

Как закрепить правило в конкретном стеке

\n

В Java Platform Module System модуль объявляет requires для зависимостей и exports для пакетов, доступных извне. Это физический механизм языка, но он не заменяет решение о владельце операции. В Spring Modulith логические модули выводятся из структуры пакетов; для них можно проверять отсутствие циклов, доступ только через API-пакеты и явно разрешённые зависимости.

\n
var modules = ApplicationModules.of(Application.class);\nmodules.verify();
\n

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

\n

Порядок внедрения без большой переделки

\n
  1. Выберите один болезненный стык. Возьмите изменение, которое регулярно цепляет чужую внутренность. Не перестраивайте весь монолит до первого измеримого результата.
  2. Опишите потребность. Назовите consumer, ожидаемый результат, владельца смысла и данные, которые должны остаться внутри.
  3. Сузьте поверхность. Оставьте команду, запрос, событие или порт. Не публикуйте namespace только потому, что он доступен компилятору.
  4. Запишите разрешённое направление. Формат from → to.surface дополните запрещённой обратной связью и владельцем исключения.
  5. Снимите baseline. Зафиксируйте список фактических импортов и хотя бы один отрицательный случай: internal-протечку, неизвестный модуль или цикл.
  6. Переведите один вызов. Сначала добавьте API и тест, затем уберите старый импорт. Оставьте обратимый путь до подтверждения потребителей.
  7. Включите проверку в review или CI. Документ без автоматического отказа быстро становится устным соглашением. Сообщение об ошибке должно назвать consumer, owner, surface и допустимый маршрут.
\n

Ограничения и отрицательный путь

\n

Граф зависимостей отвечает за структуру, но не за транзакции, задержку, права доступа, размер payload, версионирование событий, миграции схемы и качество данных. Разрешённая стрелка может вести к медленной операции, а временно запрещённая — стать допустимой после смены владельца. Поэтому архитектурный PASS не заменяет нагрузочный, security, контрактный и интеграционный тест.

\n

Не переносите правило catalog.internal в универсальную истину для любого фреймворка. В Java package-private, JPMS exports и Spring Modulith дают разные уровни защиты; в JavaScript или PHP часть границ может остаться договором, статическим анализом и review. Открытый модуль в Spring Modulith также меняет правила доступа и может быть переходным решением для legacy-кода, а не целевым состоянием.

\n

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

\n

Критерий готовности

\n

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

\n

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

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/141.json b/editorial/agent-rewrites/141.json index bba7e9a..55fd3ed 100644 --- a/editorial/agent-rewrites/141.json +++ b/editorial/agent-rewrites/141.json @@ -2,6 +2,6 @@ "index": 141, "slug": "editorial-2024-02-practice-modular-monolith", "title": "Модульный монолит: как сделать границы зависимостей проверяемыми", - "excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и короткую проверку, которую можно встроить в тесты.", - "contentHtml": "

Симптом обычно выглядит безобидно: разработчик в модуле checkout добавляет импорт из catalog/internal, потому что нужный helper уже готов. Сборка проходит. Через несколько недель изменение внутреннего parser-а каталога требует искать потребителей в оплате и заказах. Команда больше не знает, какой код можно менять локально. Цена ошибки — скрытые регрессии, длинный review и рефакторинг, который нельзя выполнить по частям.

\n

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

\n

Тезис: модуль — это контракт, а не каталог

\n

У модуля есть две стороны. Первая — то, что он публикует: команда, запрос, тип или событие с понятным смыслом. Вторая — то, от чего он зависит. Если описана только первая сторона, API быстро превращается в транзит к чужим деталям. Если описана только вторая, команда видит список импортов, но не понимает, какие вызовы считаются устойчивыми.

\n

Для каждой связи полезно хранить тройку source → target.surface. Например, checkout → catalog.api означает, что checkout использует именно опубликованную поверхность каталога. Запись checkout → catalog слишком широка: она не отличает API от repository, внутреннего mapper-а и класса, который случайно объявили public.

\n

Учебный пример ниже не описывает реальный продукт и не сообщает о результатах в production. В нём четыре модуля: catalog владеет товарами, checkout собирает заказ, payments проводит оплату, notifications отправляет уведомления. Разрешены только три стрелки: checkout к API каталога, checkout к API платежей и payments к API уведомлений.

\n
Разрешённые связи в учебной модели
ОткудаКудаРешениеЧто это защищает
checkoutcatalog.apiразрешенозаказ получает товар через контракт каталога
checkoutpayments.apiразрешенозаказ не знает внутреннюю реализацию оплаты
paymentsnotifications.apiразрешеноуведомление вызывается через отдельную поверхность
любой модульчужой *.internalзапрещенодетали реализации остаются у владельца
catalogpayments.apiзапрещено в этой моделиновая стрелка требует сценария и владельца
\n
\"Матрица
Учебная матрица показывает направление связи и поверхность API. Она не получена сканированием репозитория и не доказывает устройство production-системы.
\n

Механизм границы

\n

Публичная поверхность должна выражать потребность потребителя, а не повторять внутреннюю структуру владельца. Если checkout нужен итоговый товар для расчёта цены, ему не нужен ProductEntity, JPA repository и набор внутренних преобразователей. Ему нужен узкий порт, например CatalogReader. Владелец может заменить хранение и parser, пока сохраняет смысл этого порта.

\n
/* Учебный пример. Это контракт модуля catalog, а не готовая production-модель. */\nexport type ProductQuote = {\n  sku: string;\n  price: number;\n  currency: string;\n};\n\nexport interface CatalogReader {\n  quote(sku: string): Promise<ProductQuote>;\n}\n\n// checkout импортирует только public surface:\nimport type { CatalogReader } from '../catalog/public';\n\n// Такой импорт нарушает границу:\nimport { ProductParser } from '../catalog/internal/ProductParser';
\n

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

\n

Направления должны образовывать ориентированный ацикличный граф. Цикл checkout → payments → checkout не всегда означает, что предметная модель неверна. Он означает, что текущий порядок владения не объяснён. Пока цикл существует, изменение одного модуля требует держать в голове другой, а изолированный тест и поэтапная миграция становятся дороже.

\n

Разорвать цикл можно несколькими способами. Сначала назовите операцию и её владельца. Если payments сообщает checkout о результате, событие может идти в одну сторону. Если оба модуля используют одинаковое правило, возможно, нужен небольшой тип без поведения. Если один модуль просит внутреннюю деталь другого, сначала спроектируйте порт по потребности. Пакет common не является решением сам по себе: без владельца он превращается в новую общую свалку.

\n

Симптом → причина → проверка → действие

\n
Диагностика нарушенной модульной границы
СимптомПричинаПроверкаДействие
Изменение внутреннего класса требует искать чужие вызовыПотребитель импортирует деталь вместо контрактаВыписать from, to и surface для импортаСформировать узкий API и перевести один вызов
Два модуля ссылаются друг на другаНе назван владелец операции или сообщенияПостроить граф прямых зависимостей и найти циклВыбрать владельца, событие или односторонний adapter
Все новые вызовы идут через sharedВременный helper получил неограниченную рольПроверить владельца, потребителей и срок исключенияОставить тип локальным либо вернуть поведение владельцу
Тест границ зелёный, но API отдаёт слишком многоСтруктурное правило приняли за проверку бизнес-контрактаСопоставить данные API с конкретным сценарием потребителяУточнить DTO, права, инварианты и отдельные тесты
Новая стрелка добавлена ради прохождения сборкиПравило не требует обоснования связиСпросить сценарий, владельца, альтернативу и цену связиОформить исключение с датой пересмотра или не добавлять импорт
\n

Проверка должна ловить отрицательный путь

\n

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

\n
// Учебный псевдокод проверки политики.\nconst allowed = new Set([\n  'checkout->catalog:catalog.api',\n  'checkout->payments:payments.api',\n  'payments->notifications:notifications.api',\n]);\n\nfunction check(reference) {\n  if (reference.surface.endsWith('.internal')) return 'reject: internal';\n  const key = `${reference.from}->${reference.to}:${reference.surface}`;\n  return allowed.has(key) ? 'accept' : 'reject: direction';\n}\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.api' });\n// accept\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.internal' });\n// reject: internal
\n

Этот фрагмент проверяет только заранее переданную политику. Он не читает файлы, не строит AST, не сканирует package graph и не доказывает отсутствие нарушений в конкретном репозитории. Для реального проекта нужен инструмент, который видит фактические зависимости исходного кода, а затем тот же инструмент должен быть подключён к обычной проверке проекта. Учебный псевдокод помогает проверить форму правила, но не заменяет такой анализ.

\n

Порядок внедрения

\n
  1. Выберите один болезненный стык. Возьмите участок, где изменение часто затрагивает чужой internal-код или где уже виден цикл. Не начинайте с переименования всего монолита.
  2. Назовите владельца. Запишите, какой модуль отвечает за данные, инварианты и смысл операции. Потребитель не становится владельцем только потому, что первым вызвал функцию.
  3. Опишите поверхность. Дайте API имя по потребности: CatalogReader, а не CatalogInternals. Перечислите, что остаётся закрытым.
  4. Зафиксируйте тройку связи. Для каждого межмодульного вызова укажите source → target.surface. Запрещённые направления запишите явно.
  5. Добавьте положительный и отрицательный тест. Разрешённая связь должна проходить. Internal-импорт, неизвестная поверхность и цикл должны получать понятный отказ.
  6. Переведите один вызов. Сначала замените один импорт на API. После этого проверьте, что старый internal-символ больше не нужен потребителю.
  7. Оформите исключение отдельно. Если временный обход неизбежен, укажите сценарий, владельца, срок пересмотра и способ удаления. Комментарий без проверки не создаёт границу.
\n

Ограничения

\n

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

\n

Физическая модульность зависит от стека. Java Platform Module System умеет ограничивать экспорт пакетов, но многие приложения живут в обычном classpath. Spring Modulith предлагает проверку application modules, API-пакетов, циклов и явно разрешённых зависимостей, но это решение для Spring-стека. В TypeScript или другом языке понадобится другой анализатор. Переносить аннотации без переноса семантики бесполезно.

\n

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

\n

Критерий готовности

\n

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

\n

Проверяемые источники

\n" + "excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в тесты.", + "contentHtml": "

Сбой начинается с безобидного импорта. Код оформления заказа берёт ProductParser из catalog.internal, потому что нужного метода в API каталога пока нет. Сборка проходит, тест на один сценарий тоже. Через месяц изменение формата цены требует искать потребителей в checkout и оплате. Команда уже не знает, какой класс можно менять локально, а какой стал неявным контрактом. Цена ошибки — связанный релиз и ревью, в котором границу приходится восстанавливать по памяти.

\n

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

\n

Модуль — это контракт, а не каталог

\n

У модуля есть предоставляемая и требуемая стороны. Предоставляемая сторона — команда, запрос, порт, тип или событие, которым могут пользоваться другие части системы. Требуемая сторона — контракты, от которых модуль зависит. Одного списка публичных классов мало: он не объясняет, кому разрешено обращение и зачем.

\n

Для каждой связи записывайте тройку consumer → owner.surface. Запись checkout → catalog слишком широка: она допускает API, repository и внутренний mapper. Запись checkout → catalog.api уже задаёт объект проверки. Если нужен новый смысл, владелец каталога решает, добавить ли узкий метод, событие или оставить операцию в checkout.

\n

Учебная модель использует четыре условных модуля: catalog владеет товарами, checkout собирает заказ, payments проводит оплату, notifications отправляет уведомления. Разрешены три связи. Эти имена, стрелки и выводы не описывают реальный продукт, репозиторий или production-метрики.

\n
Матрица разрешений для учебного сценария
ПотребительВладелец и surfaceРешениеГраница
checkoutcatalog.apiразрешеноцена читается через контракт каталога
checkoutpayments.apiразрешенозаказ не знает внутреннюю оплату
paymentsnotifications.apiразрешеноуведомление вызывается через отдельную поверхность
любой модульчужой *.internalзапрещенодеталь остаётся у владельца
catalogpayments.apiзапрещено в моделиновая стрелка требует сценария и владельца
\n
\"Схема
Схема отделяет public API от internal-зоны и показывает направление связи. Это проектное правило учебного примера, а не результат сканирования исходников.
\n

Как поверхность удерживает границу

\n

Хороший API выражает потребность потребителя, а не внутреннее хранение. Если checkout нужен итоговый товар для расчёта цены, ему не нужен ProductEntity, JPA repository и внутренний parser. Ему нужен узкий порт. Владелец может поменять таблицу и способ разбора данных, если сохраняет смысл порта и его контракт.

\n
/* Учебный пример: публичный порт catalog. */\nexport type ProductQuote = {\n  sku: string;\n  price: number;\n  currency: string;\n};\n\nexport interface CatalogReader {\n  quote(sku: string): Promise<ProductQuote>;\n}\n\n// Разрешённый импорт:\nimport type { CatalogReader } from '../catalog/public';\n\n// Запрещённый импорт:\nimport { ProductParser } from '../catalog/internal/ProductParser';
\n

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

\n

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

\n

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

\n

Симптом → причина → проверка → действие

\n
Диагностика протечки границы
СимптомПричинаПроверкаСледующее действие
Изменение внутреннего класса требует правок у потребителяПотребитель импортирует деталь вместо контрактаВыписать consumer, owner и surfaceСформировать узкий порт и перевести один вызов
Два модуля ссылаются друг на другаНе выбран владелец процесса или сообщенияПостроить граф прямых зависимостейВыбрать событие, orchestration или перенос операции
Новые вызовы уходят в sharedВременный helper стал общей точкой входаПроверить владельца каждого типа и поведенияВернуть код владельцу или разделить контракты
Граница зелёная, но API отдаёт таблицу целикомСтруктурное правило приняли за бизнес-контрактСопоставить ответ с конкретной потребностьюСузить DTO и добавить функциональный тест
Тест ничего не нашёлПроверен не тот package graph или только модельСверить область анализа и фактические импортыИсправить scope, затем повторить отрицательный тест
\n

Проверка в Java-проекте

\n

Учебная матрица помогает договориться, но не читает исходники. Для Java-проекта с JUnit 5 можно подключить ArchUnit и проверять скомпилированные классы. Версия 1.1.0 была доступна в феврале 2024 года; в новом проекте версию нужно сверить с JDK, JUnit и сборкой, а не копировать без проверки.

\n
// src/test/java/com/example/ArchitectureTest.java\npackage com.example;\n\nimport static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;\nimport static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;\n\nimport com.tngtech.archunit.junit.AnalyzeClasses;\nimport com.tngtech.archunit.junit.ArchTest;\nimport com.tngtech.archunit.lang.ArchRule;\n\n@AnalyzeClasses(packages = \"com.example\")\nclass ArchitectureTest {\n  @ArchTest\n  static final ArchRule checkout_does_not_use_catalog_internals =\n      noClasses()\n          .that().resideInAnyPackage(\"..checkout..\")\n          .should().dependOnClassesThat()\n          .resideInAnyPackage(\"..catalog.internal..\");\n\n  @ArchTest\n  static final ArchRule modules_are_free_of_cycles =\n      slices().matching(\"com.example.(*)..\").should().beFreeOfCycles();\n}
\n

Минимальная настройка Gradle и запуск конкретного теста выглядят так:

\n
testImplementation(\"com.tngtech.archunit:archunit-junit5:1.1.0\")\n\n./gradlew test --tests com.example.ArchitectureTest
\n

Первое правило запрещает классам checkout зависеть от пакета catalog.internal. Второе рассматривает сегмент после com.example как slice и ищет цикл между такими сегментами. Если в проекте другая структура пакетов, шаблон нужно изменить. Иначе тест может быть зелёным просто потому, что анализирует не тот scope.

\n

На этапе диагностики можно быстро найти очевидные нарушения:

\n
rg -n \"import .*catalog\\.internal|from .*catalog/internal\" src/main src/test\n\n# После миграции production-исходники не должны дать результатов.\nrg -n \"catalog\\.internal\" src/main || true
\n

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

\n

Как описать связь до написания кода

\n

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

\n
Минимальная запись межмодульного контракта
ПолеПримерПроверяемый вопрос
Сценарийрассчитать цену позициикакую потребность покрывает вызов?
Потребительcheckoutкто инициирует обращение?
Владелецcatalogкто меняет инвариант и контракт?
Поверхностьcatalog.api.CatalogReaderкакой символ разрешён?
Запретcatalog.internal.*какой близкий путь должен ломать тест?
Направлениеcheckout → catalogне появился ли обратный вызов?
\n

Если для метода нельзя заполнить сценарий и владельца, проблема находится раньше реализации. Не публикуйте целый namespace «на будущее»: поверхность растёт, а решение о данных откладывается. Узкий контракт проще проверить и заменить.

\n

Порядок внедрения без большой переделки

\n
  1. Выберите один болезненный стык. Возьмите импорт, который регулярно тянет чужую внутренность или замыкает обратную связь. Не перестраивайте весь монолит до появления первого правила.
  2. Снимите baseline. Соберите фактические межмодульные ссылки и отделите старый долг от разрешённых исключений. Иначе первая проверка смешает регрессии с уже известными нарушениями.
  3. Назначьте владельца. Запишите, какой модуль отвечает за данные, инвариант и изменение контракта. Потребитель не становится владельцем только потому, что первым вызвал функцию.
  4. Сузьте поверхность. Оставьте команду, запрос, порт или событие, выражающие потребность. Entity, repository и mapper не должны попасть в API случайно.
  5. Опишите граф. Зафиксируйте from → to.surface, разрешённые направления и запрет на internal-доступ. Для временного исключения добавьте владельца и дату пересмотра.
  6. Добавьте положительный и отрицательный тест. Разрешённый вызов должен проходить, internal-импорт и цикл — ломаться с понятной причиной. Положительный тест без отказа легко перестаёт защищать границу.
  7. Переведите один вызов. Замените один импорт на публичный порт, запустите тесты и убедитесь, что старый символ больше не нужен. Удаляйте внутренний доступ отдельным изменением, если так проще откатить результат.
  8. Запускайте правило вместе с кодом. Архитектурный тест должен входить в обычную проверку проекта. Диаграмма без автоматического отрицательного пути быстро устаревает.
\n

Ограничения применимости

\n

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

\n

Обычные пакеты Java не равны именованным модулям Java Platform Module System. JLS описывает exports и явные зависимости для модульной системы, но приложение на classpath может оставить больше доступных типов. Spring Modulith добавляет модель логических модулей Spring Boot и проверку API-пакетов, циклов и разрешённых зависимостей. Это framework-specific механизм, а не обязательная архитектура любого монолита.

\n

ArchUnit анализирует импортированные скомпилированные классы. Он проверяет только область, указанную в @AnalyzeClasses, а качество результата зависит от базового пакета и исключений. Reflection, SQL-зависимости, сгенерированный код и внешние сервисы требуют других проверок. Поэтому «цикл не найден» означает «цикл не найден в проверенном графе классов», а не «архитектура доказана».

\n

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

\n

Критерий готовности

\n

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

\n

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

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/142.json b/editorial/agent-rewrites/142.json index c24c935..cd31c96 100644 --- a/editorial/agent-rewrites/142.json +++ b/editorial/agent-rewrites/142.json @@ -2,6 +2,6 @@ "index": 142, "slug": "editorial-2024-01-field-legacy-modernization", "title": "Модернизация legacy-системы: как ограничить rollout и не перепутать rollback с восстановлением данных", - "excerpt": "Пошаговая схема замены одного участка legacy-системы: наблюдаемый симптом, decision gate, control, ограниченная волна и отдельная проверка обратимости данных.", - "contentHtml": "

В день переключения новый обработчик отвечает успешно, но команда не может быстро ответить на четыре вопроса: какой трафик он получил, с чем его сравнивать, кто остановит волну и что произойдёт с уже записанными данными. Обычно звучит: «включим на десять процентов, а если что — откатим». Процент не задаёт границу риска. Слово «откат» не объясняет, вернётся ли только маршрут или ещё и состояние системы.

\n

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

\n

Тезис. Безопасная замена legacy — это последовательность границ: один маршрут, явный владелец, сравнимый control, ограниченное окно и отдельно описанный путь возврата. Rollout отвечает на вопрос «кому разрешено увидеть новый код». Rollback-route отвечает на вопрос «куда направить следующий запрос». Восстановление данных отвечает на другой вопрос: «что делать с эффектами, которые уже произошли».

\n

Сначала найти шов

\n

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

\n

Прокси или адаптер принимает запрос и выбирает старый либо новый обработчик. Сначала он может передавать запрос в legacy без изменения. Затем команда добавляет новый обработчик за той же границей. Такой подход оставляет старый путь доступным, пока новый контракт не проверен. Маршрут должен быть перехватываемым, а состояние — достаточно понятным для сравнения.

\n
Что должно быть известно до ограниченного включения
ПолеПримерЗачем нужно
ШовGET /catalog/itemОграничивает область изменения
ВладелецКоманда каталогаНазначает решение
ControlТот же запрос через legacyДаёт точку сравнения
СигналКод ответа и времяЗадаёт наблюдаемый признак
ВозвратПредыдущая версия правилаПоказывает обратимое действие
ДанныеТолько чтениеОтделяет маршрут от восстановления
\n

Таблица не заменяет проверку поведения. Одинаковый статус 200 может скрывать другой набор полей, задержку или побочный эффект. Для каждого шва нужны valid, invalid и repeat-сценарии. Повтор особенно важен для операций с ключом идемпотентности: одинаковый запрос не должен создать второй эффект.

\n
\"Схема
Учебная схема показывает порядок решения, а не выполненный rollout. В ней нет реальных процентов трафика, метрик или доказательства успешного возврата.
\n

Механизм: gate, control и окно

\n

Decision gate должен проверять один класс риска. Gate шва проверяет область и владельца. Gate совместимости проверяет входы, ответы и эффекты. Gate доставки проверяет версию адаптера и правило маршрутизации. Gate наблюдения проверяет control, population, duration и источник сигнала. Gate возврата проверяет только обратимое действие. Статус одного gate не доказывает остальные.

\n

Ограниченная волна имеет смысл только рядом с control. Если новый путь обслуживает пользователей без скидок, а legacy — остальных, различие может объясняться составом аудитории. Если окно короче агрегации метрики, сигнал опоздает. Если оба пути используют общий кеш или базу, новый код способен изменить поведение старого. Такое наблюдение останавливает вывод «новая версия сломана», но не отменяет расследование.

\n

Учебный пример ниже показывает форму решения для операции чтения. Имена и значения вымышлены; пример не сообщает о реальном сервисе, трафике или измерении.

\n
const gate = {\n  seam: 'catalog.item.read',\n  owner: 'catalog-team',\n  population: 'tenant=demo',\n  control: 'legacy-v3',\n  candidate: 'adapter-v1',\n  duration: '15m',\n  signal: ['status_code', 'latency_ms', 'schema_diff'],\n  decision: 'manual_review',\n  rollbackRoute: 'route -> legacy-v3',\n  dataEffect: 'read-only',\n};\n\nif (gate.dataEffect !== 'read-only' && !reconciliationOwner) {\n  throw new Error('data boundary is unknown');\n}
\n

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

\n

Симптом → причина → проверка → действие

\n
Диагностика готовности к замене
СимптомПричинаПроверкаДействие
Обсуждают только процентПроцент приняли за стратегиюНазвать population, control, duration и signalОстановить включение
Оба пути вернули 200Сравнили транспорт, не поведениеПроверить поля, ошибки, время и эффектДобавить case и владельца
«Откат» означает выключение флагаМаршрут смешали с даннымиПеречислить записи и внешние вызовыОписать reconciliation или запретить запись
Сигнал нового пути хуже controlРазличается population или shared stateСопоставить запросы, окно и зависимостиПоставить волну на паузу
Неизвестно, кто вернёт маршрутНет владельца и prior stateПроверить возврат без пользовательского трафикаНе выдавать разрешение
\n

Порядок действий

\n
  1. Выберите один шов и запишите владельца, потребителей и границу состояния.
  2. Опишите legacy-контракт: успешный вход, отказ, повтор и побочный эффект.
  3. Поставьте адаптер перед старым обработчиком и проверьте исходный маршрут.
  4. Добавьте новый обработчик за той же границей. Начните с чтения или однозначно сверяемого эффекта.
  5. Определите control, population, duration, сигналы и источник каждого сигнала.
  6. Проверьте valid, invalid и repeat на одинаковых входах.
  7. Проведите ограниченную волну с ручным решением: расширить, остановить или вернуть маршрут.
  8. Отдельно подтвердите возврат маршрута и состояние данных.
  9. Расширяйте область только после разбора существенных расхождений. Unknown оставляйте на legacy.
\n

Почему rollback не исправляет данные

\n

Для read-only шва возврат маршрута обычно проще: следующий запрос снова идёт в известную версию. Но общий кеш, sticky session или изменённая схема могут связать пути. Нужно проверить, что старый обработчик принимает текущее состояние и что новый код не изменил его косвенно.

\n

Для write-операции новый обработчик мог создать заказ, отправить сообщение, вызвать платёжный шлюз или записать событие. Отключение адаптера остановит новые вызовы, но не отменит внешний эффект. Компенсация может быть невозможна, дублировать действие или потребовать бизнес-решения. Перед такой миграцией нужны record id, журнал состояния, владелец сверки и ответ для повторной доставки.

\n

Если команда не может назвать эти элементы, не пишите аварийный delete-скрипт. Сузьте первый шов до чтения, добавьте preview или оставьте запись в legacy. Отложенное изменение сохраняет управляемость. Быстрое переключение без границы данных переносит проблему в момент, когда исправление дороже.

\n

Ограничения и критерий готовности

\n

Canary не заменяет тесты. Небольшая доля трафика снижает область воздействия, но не доказывает полноту поведения. Synthetic нагрузка не показывает все состояния реальных пользователей. Общие базы, кеши, очереди и внешние провайдеры могут испортить независимость control и нового пути. Автоматический rollback полезен только там, где действие действительно обратимо.

\n

Универсального безопасного процента нет. Для системы, где каждый запрос меняет баланс, десять процентов могут быть слишком много. Для чтения сто процентов допустимы после проверки совместимости. Число выбирают после определения population, эффекта и времени обнаружения проблемы.

\n

Критерий готовности проверяем. Другой инженер без устного контекста может показать владельца; legacy и candidate версии; control; population и duration; сигналы и их источники; valid, invalid и repeat cases; действие возврата; список необратимых эффектов и владельца сверки. Команда может выполнить безопасное обратное переключение на тестовом контуре и увидеть, куда пойдут следующие запросы. Если пункт неизвестен, готовность не доказана и волну не расширяют.

\n

Проверяемые источники

\n" + "excerpt": "Практическая схема замены одного участка legacy-системы: узкий шов, сравнимый control, ограниченная волна и отдельная проверка обратимости данных.", + "contentHtml": "

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

\n

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

\n

Главная граница. Rollout отвечает на вопрос «кому разрешён новый код». Rollback маршрута отвечает на вопрос «куда направить следующий запрос». Восстановление данных отвечает на вопрос «как обработать эффект, который уже произошёл». Это связанные, но разные действия.

\n

Выберите шов, а не весь проект

\n

Шов — участок поведения, который можно отделить от остальной системы: чтение карточки товара, расчёт тарифа или получение профиля. Для первой волны подходит операция с понятными входами, ограниченным числом потребителей и наблюдаемым результатом. Если операция меняет деньги, права или вызывает внешний сервис, граница должна включать эти эффекты, а не только HTTP-ответ.

\n

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

\n
Минимальная карточка шва перед первым включением
ПолеПримерЧто должно быть проверено
ОперацияGET /catalog/items/{id}Понятно, какой запрос входит в волну
ВладелецКоманда каталогаЕсть ответственный за stop/continue
Controllegacy-v3Есть известная версия для сравнения
Candidateadapter-v1Новый путь виден в логах и метриках
СостояниеТолько чтениеRollback маршрута не обещает откат записи
Сигналы5xx, p95, schema diffУ каждого сигнала есть источник и порог
\n
\"Петля
Схема показывает порядок принятия решения для учебного read-only шва. Это не отчёт о реальном трафике, метриках или выполненном возврате маршрута.
\n

Поставьте proxy и сохраните старый контракт

\n

Шов обычно оформляют как proxy, adapter или anti-corruption layer. Сначала слой пропускает все вызовы в legacy без изменения. Затем для выбранной операции он отправляет запрос в candidate, преобразует его внутренний ответ в прежний внешний контракт и позволяет одним изменением правила вернуть следующие запросы в legacy. Потребители не обязаны мигрировать одновременно с серверной реализацией.

\n

У proxy есть собственный риск: он может стать единой точкой отказа или узким местом. Его latency и ошибки измеряют отдельно, а исходный pass-through маршрут проверяют до включения candidate. Наличие прокси само по себе не доказывает готовность: нужны его версия, конфигурация и поведение при недоступности нового обработчика.

\n

Следующая команда показывает форму сравнения двух read-only маршрутов. Заголовок X-Route — условный интерфейс тестового адаптера; его нельзя посылать в произвольный production endpoint. Подставьте документированный способ выбрать control и candidate. Нормализуйте только поля, которые заранее признаны техническими.

\n
test -n \"$BASE_URL\" || { echo \"set BASE_URL\" >&2; exit 2; }\ntest -n \"$ITEM_ID\" || { echo \"set ITEM_ID\" >&2; exit 2; }\n\nfor route in legacy candidate; do\n  curl --fail-with-body --silent --show-error \\\n    -H \"X-Route: $route\" \\\n    \"$BASE_URL/catalog/items/$ITEM_ID\" > \"$route.json\"\ndone\n\njq -S 'del(.requestId, .generatedAt)' legacy.json > legacy.normalized.json\njq -S 'del(.requestId, .generatedAt)' candidate.json > candidate.normalized.json\ndiff -u legacy.normalized.json candidate.normalized.json
\n

Нулевой diff проверяет только один вход и выбранную нормализацию. Добавьте valid, invalid, отсутствующий идентификатор, forbidden и повторный запрос. Если вызов имеет скрытый побочный эффект, сравнение JSON не заменяет журнал вызовов и сверку состояния.

\n

Сделайте canary измеримым

\n

Canary — не произвольные «десять процентов». В модели Google SRE candidate получает подмножество population на ограниченное время, а оставшаяся часть служит control. Нужны способ разделить трафик, процедура оценки и связь этой оценки с решением о выпуске. Разделителем может быть tenant, стабильная группа пользователей, отдельный маршрут или версия на балансировщике.

\n

Control должен быть сопоставим с candidate. Если новый код обслуживает только новых пользователей, различие может объясняться population. Если метрика строится каждый час, а волна длится пятнадцать минут, сигнал запоздает. Общая база, кеш или очередь могут позволить candidate повлиять на control, поэтому сравнение дополняют абсолютным SLO.

\n
Пример decision gate для учебной волны
ИзмерениеИсточникЕсли результат неизвестен
HTTP 5xx по версииСчётчик запросов proxyПоставить волну на паузу
p95 latencyHistogram одного маршрутаНе расширять population
Схема ответаНормализованный contract diffРазобрать поле и потребителей
Повторный эффектrecord id или idempotency keyОстановить write-path
Состояние controlАбсолютный SLOОтделить общий сбой от candidate
\n

Порог — проектное решение, не универсальная цифра. Его связывают с SLO, размером выборки, длительностью окна и ценой ошибки. Один успешный ответ не доказывает совместимость, а хороший общий error rate может скрыть редкий дорогой сценарий.

\n

Отделите симптом от вывода

\n
Диагностическая матрица перед расширением
СимптомГипотезаПроверкаДействие
Обсуждают только процентРазмер подменил стратегиюНазвать population, control, window и ownerНе включать candidate
Оба пути вернули 200Сравнили транспорт, не смыслПроверить поля, ошибки, latency и effectДобавить contract cases
Candidate медленнее вечеромРазличается нагрузка или cache stateСопоставить окно, ключ и backendПовторить в сопоставимой группе
После rollback появились дублиЗапись уже произошлаСверить record id и журналОстановить запись, не делать blind delete
Никто не нажимает stopНет владельца и prior stateПроверить runbook на тестовом контуреВернуть решение на подготовку
\n

Рост 5xx в candidate — сигнал остановиться, но не автоматическое доказательство причины: общая зависимость и несовпадающие окна могут менять оба пути. И наоборот, хороший A/B-график не отменяет проверки денежных, правовых и permission-sensitive эффектов.

\n

Проведите волну как runbook

\n
  1. Опишите один шов, его потребителей, владельца и границу состояния.
  2. Зафиксируйте legacy-контракт: успешный вход, отказ, права, повтор и побочный эффект.
  3. Поставьте proxy в pass-through и проверьте старый маршрут.
  4. Подготовьте candidate с тем же внешним контрактом и отдельной версией в логах.
  5. Сделайте dry run: valid, invalid, not found, forbidden и repeat.
  6. Задайте population, control, duration, сигналы, пороги и владельца решения.
  7. Включите стабильную малую группу на окно, покрывающее реальную рабочую нагрузку.
  8. Сравните candidate с control и абсолютным SLO; unknown трактуйте как stop.
  9. При остановке зафиксируйте состояние маршрута и переключите следующие запросы на известную версию.
  10. Отдельно проверьте record id, внешние вызовы и очередь, затем решайте: расширять, чинить вперёд или восстанавливать данные.
\n

Rollback не отменяет уже созданный эффект

\n

Для read-only шва rollback часто означает смену правила proxy: следующие запросы снова идут в legacy. Но общий кеш, sticky session, изменённая схема или миграция справочника могут связать старый и новый пути. Перед возвратом нужно проверить, что legacy понимает актуальное состояние и candidate не изменял его косвенно.

\n

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

\n

AWS различает cutover без новых данных и возврат после появления новых транзакций: во втором случае нужен отдельный план работы с данными, например fail-forward, dual-write или проверенное восстановление из backup. Это не универсальный рецепт. Способ выбирают по модели владения данными, RPO/RTO, идемпотентности и требованиям бизнеса.

\n

Минимальный набор для write-path — record id, журнал переходов, idempotency key или доказанное отсутствие повторной доставки, владелец сверки и порядок действий при расхождении. Если элементов нет, первый шов лучше сузить до чтения, добавить preview или оставить запись в legacy. Аварийный delete-скрипт без карты зависимостей — не стратегия восстановления.

\n

Ограничения и критерий готовности

\n

Canary снижает размер воздействия, но не заменяет тесты и не даёт статистической гарантии. Маленькая population может не содержать редкие роли. Synthetic traffic не воспроизводит все реальные состояния. Общие backend-компоненты нарушают независимость control и candidate. Система с балансами, правами или внешними эффектами требует более строгой границы, чем read-only endpoint.

\n

Универсального безопасного процента нет. Десять процентов запросов к операции, меняющей баланс, могут быть чрезмерным риском; для чтения сто процентов могут быть приемлемы после проверки совместимости. Размер и duration выбирают так, чтобы увидеть релевантную нагрузку, не потратить незаметно error budget и успеть остановить волну до необратимого ущерба.

\n

Готовность доказана, когда без устного контекста можно показать точный шов, control и candidate, population, окно, источники сигналов, тестовые cases, stop/rollback runbook, список необратимых эффектов, владельца сверки, совместимость схемы и безопасное поведение при недоступности candidate. Пока хотя бы один пункт неизвестен, волну не расширяют.

\n

Проверяемые источники

\n" }