diff --git a/editorial/agent-rewrites/131.json b/editorial/agent-rewrites/131.json index 1896c1c..294d65e 100644 --- a/editorial/agent-rewrites/131.json +++ b/editorial/agent-rewrites/131.json @@ -1 +1 @@ -{"index":131,"slug":"editorial-2024-05-mechanism-platform-templates","title":"Платформенный шаблон как контракт: базовый путь, расширение и отказ","excerpt":"Шаблон ускоряет повторяемый старт, если ограничивает вход, называет владельца и умеет остановиться. Разбираем контракт параметров, extension point, отрицательный путь и границы repository templates.","contentHtml":"
Новая команда просит создать сервис. Форма платформы принимает имя, владельца, runtime и несколько флагов. Через месяц в созданном репозитории появляются ручные исключения в CI, другой healthcheck и особая политика хранения. Команда называет это вариантом шаблона, хотя базовый путь уже не описывает результат. Симптом виден в первом локальном patch после генерации.
Цена ошибки складывается из трёх частей. Reviewer восстанавливает исходное решение по разрозненным изменениям. Platform team поддерживает комбинации, для которых никто не назначил владельца. Команда-потребитель ждёт обновлений от шаблона, но получает самостоятельную копию. Чем больше полей добавляет форма, тем дороже становится неизвестность.
Тезис статьи простой: платформенный шаблон должен принимать закрытый класс повторяемых задач. Совпадение с базовым контрактом ведёт в golden path. Одно заранее названное отличие ведёт на review расширения. Изменение инварианта или неизвестная политика должны остановить шаблон. Право сказать нет — часть механизма, а не неудобная ошибка интерфейса.
Разделите шаблон на три слоя. Первый слой принимает факты: тип компонента, owner, runtime, класс данных и требования к доставке. Второй слой проверяет контракт: допустимы ли значения, совпадают ли они с версией base path, есть ли у отличия имя и владелец. Третий слой выдаёт один из трёх результатов: golden path, review extension или decline.
Такой порядок не делает архитектуру автоматической. Он не выбирает policy за доменную команду. Он не доказывает, что generated repository можно отправить в production. Он лишь не даёт форме превратить незакрытый вопрос в якобы безопасный default.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Просят универсальный флаг | Поле меняет runtime, owner, data class, доступ или retention | Сравнить поле с базовым инвариантом и назвать owner решения | Убрать поле из golden path; вынести запрос на design path |
| Сразу после создания нужен ручной patch | Различие не оформлено как versioned extension | Проверить identifier, owner, boundary и rollback | Остановить расширение; отправить его на review |
| Шаблон используют для одноразовой миграции | Нет повторяемого типа компонента и понятного lifecycle | Проверить, повторится ли тот же контракт для другой заявки | Отказать шаблону и провести отдельное решение |
| Копию считают fork-ом | Смешаны копирование файлов и наследование изменений | Проверить историю и реальный канал обновлений | Зафиксировать самостоятельное владение или выбрать другой механизм |
У формы должен быть конечный список допустимых значений. В учебном примере базовый путь создаёт только внутренний HTTP-сервис. Для него известны owner, runtime и класс данных. Расширение добавляет observability adapter, но не меняет runtime, owner или data class. Любое лишнее поле шаблон отклоняет до создания репозитория.
# Учебный пример, не production-конфигурация: apiVersion: scaffolder.backstage.io/v1beta3; kind: Template; metadata.name: internal-http-service; spec.parameters.required: [name, owner, runtime, dataClass]; spec.parameters.properties.runtime.enum: [node20, go122]; spec.parameters.properties.dataClass.enum: [internal]; spec.parameters.properties.extension.enum: [none, observability-adapter]; spec.steps: [fetch:template, publish:github]Фрагмент только показывает границу входа. Enum ограничивает форму, но не заменяет проверку. Значение owner должно ссылаться на существующую группу. Action публикации требует прав и credentials. Нужны отдельные проверки secrets, branch protection, pipeline, healthcheck и rollback. Учебный пример не подтверждает пригодность runtime и не создаёт policy организации.
Главное правило — не передавать через форму то, что меняет смысл базового сервиса. Поле runtime с двумя разрешёнными значениями может быть частью закрытого контракта. Поле customRuntimeConfig открывает неограниченное пространство решений. Поле retentionPolicy нельзя считать безобидным расширением, если оно меняет требования к данным и доступу.
Base path фиксирует повторяемую часть: компонент известного типа, назначенного владельца, одобренный runtime и заранее определённый класс данных. Он может создать skeleton, metadata и список обязательных проверок. Он не должен принимать вопрос «какой runtime выбрать позже». Отложенное решение не становится безопасным оттого, что его записали в параметр.
Extension point нужен для узкого отличия, которое не ломает базовые инварианты. У расширения должны быть identifier, owner, версия, граница изменений и условие удаления. Observability adapter подходит как учебный пример, если он добавляет один известный слой наблюдаемости. Новый класс данных, внешний доступ или другая модель retention уже меняют тип решения. Их нельзя спрятать за словом extension.
Проверяйте расширение до генерации. Сначала найдите canonical record для типа заявки. Затем сравните с ним все входы. Если запись не найдена, результатом должен быть decline. Если совпал base contract и добавлено одно разрешённое отличие, создайте review record. Только после review запускайте action, который публикует файлы.
GitHub repository template создаёт новый репозиторий с той же структурой и файлами. GitHub отдельно указывает, что ветви такого репозитория имеют несвязанные истории. Это полезный старт, но не канал автоматической синхронизации с шаблоном. Команда получает начальный набор файлов и дальше принимает собственный жизненный цикл, если другой договор не определён отдельно.
Fork решает другой вопрос. Он сохраняет историю родительского репозитория и подходит для совместной работы с исходным проектом. Нельзя обещать потребителю updates от template только потому, что интерфейс запуска называется похожим образом. Перед внедрением зафиксируйте, что именно распространяется: копия файлов, pull request-ы, пакет, action или самостоятельная версия. У механизма должен быть владелец доставки изменений.
Backstage Software Templates тоже не заменяют этот договор. Scaffolder принимает параметры, выполняет шаги, подставляет переменные и может публиковать результат в GitHub или GitLab. Эти возможности описывают способ создания компонента. Они не доказывают, что любой action разрешён, что вход соответствует policy или что созданные репозитории будут синхронизироваться. Контракт команды остаётся отдельным слоем.
Шаблон не выбирает owner за команду, не проводит threat modeling, legal review или capacity planning. Он не устанавливает SLO и не определяет правила хранения данных. Backstage и GitHub документируют свои механизмы, но не знают сетевые права и требования конкретной организации. Эти проверки нельзя заменить несколькими полями формы.
Отказ должен возвращать причину и следующий вопрос. Например: «decline-template: нет стабильного component type; назначьте owner и определите data class». Артефакт не создаётся. Команда получает design record и может вернуться к шаблону после решения. Такой отказ дешевле fork-а, который выглядит стандартным только до первой ручной переделки.
Rollback должен быть коротким. Для не прошедшего review удаляется draft contract или extension proposal, а не чужой repository. Для ошибочной версии возвращаются к предыдущему base contract. Если шаблон уже публикует внешние ресурсы, отдельно документируйте компенсационные действия; форма сама по себе не делает их обратимыми.
Механизм готов, если другая команда получает тот же вердикт по тем же фактам. Допустимая заявка проходит базовый путь. Узкое расширение содержит identifier, owner, boundary, version и rollback. Несовместимая заявка останавливается до публикации и возвращает понятную причину. Повторный запуск не создаёт дублирующие обязательные действия.
Проверка состоит из четырёх записей: базовая заявка, разрешённое расширение, несовместимая заявка и повтор базовой заявки. Сравните решения, созданные артефакты и отрицательные ветки. Готовность есть, если инварианты base path не меняются, extension нельзя активировать без review, decline не создаёт repository, а повторный запуск имеет явно определённое поведение. Это проверяемый контракт, а не обещание универсальной платформы.
Команда просит создать внутренний HTTP-сервис. В форме есть имя, владелец и несколько флагов, поэтому старт выглядит безопасным. Через месяц в репозитории появляются ручные исключения в CI, другой healthcheck и особая политика хранения данных. Первый симптом обнаруживается в diff сразу после генерации: базовый шаблон уже не объясняет, почему проекту разрешены эти отличия.
Проблема не в самом генераторе файлов. Шаблон смешал три разных решения: какой класс компонента создаём, какие инварианты обязаны сохраниться и кто разрешает исключение. Из-за этого команда-потребитель принимает копию за наследника, а platform team получает набор неименованных вариантов без владельца обновлений. Чем шире форма, тем больше скрытых комбинаций приходится поддерживать.
Рабочая модель проще: шаблон принимает закрытый класс задач и возвращает один из трёх вердиктов. Полное совпадение с базовым контрактом ведёт в golden path. Одно заранее описанное отличие отправляется на review extension. Несовместимый или неизвестный вход даёт decline до создания репозитория. Отказ — нормальный результат валидатора, а не ошибка интерфейса.
Перед изменением шаблона зафиксируйте наблюдаемый случай. Например, заявка содержит dataClass=internal, runtime из разрешённого списка и расширение observability-adapter. Такой запрос можно сравнить с базовым контрактом. Другая заявка содержит внешний доступ и индивидуальный retention. Она меняет границу риска и не должна проходить под тем же названием.
| Наблюдение | Проверка | Вердикт | Что создаётся |
|---|---|---|---|
| Все обязательные поля совпали с base contract | Тип компонента, owner, runtime, класс данных и доступ входят в разрешённые значения | Golden path | Скелет, metadata и стандартные проверки |
| Добавлено одно известное отличие | Есть identifier, владелец, граница, версия и условие удаления | Review extension | Proposal; публикация только после разрешения |
| Появился внешний доступ или другой класс данных | Сравнить инварианты и права; найти отдельный policy record | Decline | Только причина отказа и следующий вопрос |
| Поле или значение неизвестно | Проверить схему и список разрешённых action | Decline | Репозиторий не создаётся |
Термин «шаблон» описывает и механизм, и начальный набор файлов, поэтому здесь легко ошибиться. GitHub пишет, что репозиторий, созданный из template, получает структуру и файлы выбранной ветки, а ветви такого репозитория имеют несвязанные истории. Это быстрый старт нового проекта, но не обещание получать изменения из исходного репозитория.
Fork отвечает на другой вопрос. Он сохраняет историю родительского репозитория и предназначен для работы с ним через общий граф коммитов. Если команде нужны pull request-ы из исходного проекта, template не заменяет fork. Если нужен самостоятельный сервис с начальным skeleton, fork создаёт лишнюю связь и другую модель владения.
Практическое правило: в описании платформы явно напишите, что распространяется после старта. Это может быть копия файлов, пакет, action, регулярные pull request-ы или самостоятельная версия. Само слово template не выбирает канал обновлений и не назначает владельца совместимости. Отсутствие такого договора и есть причина, по которой ручной patch позже принимают за «вариант шаблона».
Закрытый контракт отвечает на четыре вопроса: какой тип компонента создаётся, кто им владеет, какие значения разрешены и какое действие выполнится после проверки. Вход не должен принимать произвольный runtime-конфиг, credential, путь в файловой системе или политику retention. Каждый такой параметр расширяет область решений быстрее, чем растёт способность команды их проверять.
Ниже — учебный фрагмент для Backstage Software Template. В нём enum ограничивает класс данных и расширение, additionalProperties закрывает страницу формы, а allowedHosts и allowedOrganizations ограничивают место публикации. Имена организации, owner и runtime здесь проектные: их нужно заменить на значения своей Backstage-инсталляции.
apiVersion: scaffolder.backstage.io/v1beta3\nkind: Template\nmetadata:\n name: internal-http-service\nspec:\n owner: platform/catalog\n type: service\n parameters:\n - title: Service contract\n required: [name, owner, dataClass]\n additionalProperties: false\n properties:\n name:\n title: Service name\n type: string\n pattern: '^[a-z][a-z0-9-]{2,30}$'\n owner:\n title: Owner group\n type: string\n ui:field: OwnerPicker\n dataClass:\n title: Data class\n type: string\n enum: [internal]\n extension:\n title: Extension\n type: string\n enum: [none, observability-adapter]\n default: none\n - title: Repository\n required: [repoUrl]\n properties:\n repoUrl:\n title: Repository location\n type: string\n ui:field: RepoUrlPicker\n ui:options:\n allowedHosts: [github.com]\n allowedOrganizations: [acme-platform]\n steps:\n - id: fetchBase\n name: Fetch base\n action: fetch:template\n input:\n url: ./skeleton\n values:\n name: ${{ parameters.name }}\n owner: ${{ parameters.owner }}\n extension: ${{ parameters.extension }}\n - id: publish\n name: Publish\n action: publish:github\n input:\n repoUrl: ${{ parameters.repoUrl }}Это не готовая policy организации. Backstage проверит форму и выполнит описанные actions, но не узнает сам, разрешён ли owner, достаточно ли прав у пользователя, соответствует ли healthcheck требованиям команды и можно ли откатить опубликованный ресурс. Эти инварианты должны проверяться отдельным action, permission policy, CI или review перед публикацией.
Проверка отрицательного пути обязательна. Подставьте dataClass=customer, неизвестное значение extension, пустой owner и лишний ключ. Ожидаемый результат — ошибка схемы до шага publish. Если неизвестный вход всё-таки доходит до публикации, закрытая форма существует только на экране, а не в контракте.
Base path — повторяемая часть решения. В нашем случае он фиксирует внутренний HTTP-сервис, назначенную группу-владельца, разрешённый runtime, внутренний класс данных и минимальный набор проверок. Версия base contract должна быть видна в документации или metadata. Иначе невозможно понять, к какой схеме относится сгенерированный проект.
Extension point разрешает узкое отличие от этой схемы. Для расширения заранее запишите пять полей: identifier, owner, границу изменения, версию и условие удаления. Observability adapter подходит как пример, если он добавляет известные метрики и не меняет доступ, класс данных или способ хранения. Внешний endpoint, новый runtime и другая retention policy уже меняют инвариант.
Такое разделение помогает не спорить о названиях. Сначала сравните заявку с canonical record. Затем проверьте, что отличие не пересекает границу base path. Если пересекает, верните decline и сформулируйте отдельный архитектурный вопрос. Если не пересекает, создайте review record, а не молча добавьте условие в основной шаблон.
У Backstage есть Template Editor с dry-run режимом. Он позволяет загрузить локальный каталог шаблона, заполнить форму и посмотреть результат выполнения actions без сохранения изменений в рабочем шаблоне. Это удобное место для проверки маршрутов, но dry-run не заменяет permission check, credentials, сетевые права и итоговый pipeline.
internal и none. Проверьте, что skeleton содержит ожидаемые metadata и список проверок.extension на observability-adapter. Сравните diff и убедитесь, что не появились внешний доступ, другой runtime или новая политика хранения.publish:github и вернуть понятную причину.Для различия template и fork можно дополнительно проверить историю после создания репозитория. URL замените на адрес своего исходного template:
git clone https://github.com/acme-platform/internal-http-template.git template\ngit clone https://github.com/acme-team/payments-api.git generated\ngit -C generated remote add template https://github.com/acme-platform/internal-http-template.git\ngit -C generated fetch template main\nif git -C generated merge-base --is-ancestor template/main HEAD; then\n echo 'generated наследует историю template'\nelse\n echo 'истории не связаны: обновления нужно доставлять отдельным механизмом'\nfiКоманда проверяет именно граф коммитов, а не похожесть файлов. Для репозитория, созданного из GitHub template, ожидается ветка с несвязанной историей. Если команда требует постоянного наследования, зафиксируйте другой механизм: fork, пакет, генератор обновлений или pull request-ы с отдельным owner.
Шаблон не назначает владельца за команду, не проводит threat modeling, legal review или capacity planning. Он не определяет SLO, не доказывает безопасность secrets и не решает, сколько стоит сопровождение внешней интеграции. Backstage предоставляет схему, actions и точки авторизации, а GitHub — механизм создания репозитория; policy конкретной организации остаётся за её владельцами.
Отказ должен быть информативным и безопасным. Сообщение вроде decline-template: внешний доступ меняет границу data class; назначьте policy owner и создайте отдельный design record объясняет следующий шаг. Репозиторий не создаётся, credentials не расходуются, а заявку можно вернуть после принятия решения. Удалять уже опубликованный чужой ресурс ради отката шаблона нельзя считать безопасным default.
Не расширяйте golden path ради редкого случая. Если одно и то же отличие повторяется, сохраняет исходные инварианты и проходит review, включите его в новую версию контракта. Если отличие одноразовое или требует другого доступа, оставьте его отдельным решением. Так количество вариантов отражает реальные договорённости, а не историю случайных ручных patch-ей.
Шаблон готов к использованию, когда две команды получают одинаковый вердикт по одинаковым фактам. Базовая заявка проходит закрытую схему. Разрешённое расширение имеет owner, границу, версию и rollback. Несовместимый запрос останавливается до публикации. Повторный запуск имеет заранее определённое поведение.
Проверяйте не количество созданных репозиториев, а четыре маршрута: base, разрешённое extension, неизвестный input и повтор base. Для каждого сохраните вход, вердикт, созданные артефакты и причину отказа. Такой набор даёт команде воспроизводимый контракт и показывает, где шаблон заканчивается. Всё, что требует нового класса данных, доступа или жизненного цикла, должно выйти за эту границу явно.
apiVersion, spec.parameters, spec.steps, actions и условия выполнения шагов.Новая команда открывает заявку на сервис и получает знакомый ответ: возьмите соседний репозиторий, замените имя, а остальное поправьте по месту. Через неделю два сервиса уже расходятся по CI, healthcheck и владельцам конфигурации. Ошибка проявляется не при создании, а на первом изменении общего правила. Review приходится сравнивать с несколькими копиями, исправление нужно переносить вручную, а rollback зависит от того, какая копия стала «правильной». Цена ошибки — не лишний файл. Команда теряет время на восстановление контракта и получает несколько путей, за которые никто не отвечает.
\nТезис простой: платформенный шаблон должен фиксировать только повторяемый класс решений. У класса есть owner, версия, обязательные входы, инварианты и ограниченный выход. Всё, что меняет этот класс, выносят в именованное расширение. Всё неизвестное отправляют на отдельное архитектурное решение. Такой шаблон остаётся коротким golden path и не превращается в форму со скрытой политикой.
\nШаблон — это не обещание готового сервиса. Он может положить согласованную структуру каталогов, подставить имя, подготовить описание компонента и передать результат в выбранное место. Backstage называет такой механизм Software Templates: он загружает skeleton, подставляет переменные и может опубликовать результат в GitHub или GitLab. Это полезная граница автоматизации. Она отвечает на вопрос «как получить одинаковый старт», но не отвечает на вопросы о доступе к данным, security review, capacity или разрешении на выпуск.
\nПоэтому сначала описывают договор, а потом файлы. Для внутреннего HTTP-сервиса договор может содержать owner, approved runtime, класс данных, обязательный health endpoint и способ регистрации в каталоге. Он также должен содержать отрицательную часть: шаблон не выдаёт production approval, не создаёт секреты, не выбирает retention и не меняет права. Без этой части любой новый параметр легко станет незаметным исключением.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В форме появляется «ещё одна галочка» для особого runtime. | В один шаблон поместили два разных класса компонентов. | Сравнить owner, runtime, data class и обязательные проверки для обоих вариантов. | Оставить один класс. Для второго открыть отдельный design path. |
| После создания каждый репозиторий правят вручную. | Выход шаблона считают договором, хотя он содержит только стартовые файлы. | Показать, какие инварианты сохраняются после правки и кто владеет каждым правилом. | Вынести повторяемую правку в версионированный шаблон или named extension. |
| Один generated repository нужно синхронизировать с базой. | Создание из template перепутали с fork или наследованием. | Проверить историю и способ доставки изменений из исходного репозитория. | Не обещать автоматическую синхронизацию. Выбрать обновление вручную или другой механизм. |
| Неизвестный владелец всё равно проходит форму. | Проверку ownership оставили на потом. | Сделать owner обязательным входом и остановить запуск при пустом значении. | Вернуть заявку на уточнение ответственности. |
| Шаблон должен выбрать retention или access policy. | Архитектурное решение спрятали в параметр интерфейса. | Спросить, кто утвердил policy и действует ли она для всего класса. | Убрать параметр из base path и провести отдельную проверку. |
Таблица нужна не для оценки удобства формы. Она разделяет повторяемое правило и исключение. Если проверка не может назвать owner и одинаковый смысл поля для нескольких задач, поле ещё не готово для base template. Если выход нельзя проверить без устной истории конкретной команды, шаблон выдаёт слишком много обещаний.
\nНиже учебный JavaScript-объект. Он не создаёт репозиторий, не вызывает CI, не меняет кластер и не подтверждает работу сервиса. Его задача — показать форму проверки. В настоящем проекте значения должны ссылаться на реальные справочники и правила доступа, а не на строки из примера.
\nconst contract = {\n templateId: 'internal-http-service',\n version: 3,\n owner: 'platform-team',\n requires: [\n 'service-name',\n 'component-owner',\n 'approved-runtime',\n 'internal-data-class',\n ],\n produces: [\n 'repository-skeleton',\n 'catalog-metadata-draft',\n 'review-checklist',\n ],\n refuses: [\n 'unknown-owner',\n 'new-retention-policy',\n 'one-off-data-migration',\n ],\n};\n\nfunction canUseTemplate(input) {\n return contract.requires.every((field) => input[field])\n && !contract.refuses.some((field) => input[field]);\n}\n\n// Учебные данные. true означает только согласованный вход.\nconsole.log(canUseTemplate({\n 'service-name': 'billing-api',\n 'component-owner': 'billing-team',\n 'approved-runtime': 'node-approved',\n 'internal-data-class': 'internal',\n}));\nПроверка возвращает true только для входа, который удовлетворяет договору. Она не говорит, что сервис безопасен или готов к выкладке. Если добавить new-retention-policy, функция должна вернуть false. Это важнее положительной ветки: неизвестная политика не должна незаметно превращаться в настройку по умолчанию.
Имена полей также задают границы ответственности. component-owner отвечает за доменный компонент. owner контракта отвечает за сам шаблон. Эти роли могут принадлежать одной группе, но смешивать их в одно не стоит. Иначе пользователь сможет создать компонент без владельца, потому что «платформа же владеет формой».
Узкий путь не означает бедный путь. Он может включать структуру документации, labels, базовый health endpoint, регистрацию компонента и обязательные проверки. Ограничение относится к смыслу выбора. Каждое поле должно иметь одинаковое значение для заявленного класса. Поле «выбрать любую базу» не является частью golden path, если разные базы меняют отказоустойчивость, хранение данных и эксплуатацию. Поле «выбрать approved runtime из двух поддерживаемых» может быть допустимым, если правила для обоих вариантов уже определены и owner готов их поддерживать.
\nУ каждого правила должен быть ответ на вопрос «кто изменит его, если оно устареет?». Ответом может быть команда платформы, владелец каталога или отдельная группа безопасности. Если ответа нет, правило нельзя считать общим. Копирование файла не назначает владельца. Число успешных запусков тоже не доказывает, что договор корректен.
\nИногда компонент остаётся в том же классе, но требует одного дополнительного подключения. Например, approved runtime и internal data class сохраняются, а команде нужен заранее описанный observability adapter. Тогда нужен named extension. У него есть имя, owner, входные условия, граница изменений, rollback и срок пересмотра. Extension добавляет output к base contract, но не заменяет owner, runtime и data policy.
\nНе называйте расширением любое изменение после создания. Локальный patch без имени и владельца не оставляет следа для следующей команды. Он быстро становится новой копией шаблона, а исправление базы не доходит до него. Если одинаковый patch повторился несколько раз, соберите факты: одинаков ли класс, одинаковы ли условия и можно ли описать rollback. Только после этого решайте, стал ли patch частью extension или отдельным шаблоном.
\nУ механики template repository есть ещё одна ловушка. GitHub предупреждает: ветви репозитория, созданного из template, имеют несвязанную историю, поэтому между ними нельзя строить обычные pull request и merge. Это не дефект GitHub. Это свойство операции создания. Значит, шаблон нельзя рекламировать как канал синхронизации с исходным репозиторием. Для долгоживущего общего кода нужен другой механизм, например пакет, зависимость или явно поддерживаемый upstream-процесс.
\nЗаявка на разовую миграцию регулируемых данных обычно не готова к service template. У неё могут быть неизвестны owner и lifecycle, а access и retention требуют отдельного решения. Если вставить такую задачу в форму, пользователь заполнит поля, но архитектура останется неразрешённой. Шаблон создаст уверенный вид, а вопросы проявятся после получения файлов.
\nОстановка не означает запрет на работу. Она возвращает заявку на правильную границу: design record, security review или обсуждение владельца данных. После нескольких одинаковых решений может появиться новый класс. Тогда его можно оформить отдельным контрактом с собственным owner и проверками. Не делайте этот вывод по одной удачной заявке.
\nШаблон не заменяет threat modeling, capacity planning, CI, тесты, миграционный план и проверку прав. Он не доказывает, что созданный сервис можно выпускать. Он также не обязан поддерживать каждый исторический репозиторий. Base contract задаёт повторяемый старт, а не универсальную архитектуру компании.
\nRollback должен быть определён на уровне изменения. Для draft удаляют draft. Для extension возвращаются к versioned base contract и удаляют подключение по его инструкции. Для уже созданного репозитория отдельно проверяют, что возвращается: файлы, зависимость, конфигурация или данные. Возврат шаблона не откатывает автоматически изменения, которые команда внесла после создания.
\nМатериал готов к применению как учебная схема, когда второй инженер без устных пояснений может показать класс, owner, версию, обязательные входы, инварианты, выход, refusal criterion и rollback. Для каждой ссылки есть источник. Для каждого неизвестного входа есть stop. В примере выше все имена и значения вымышлены; они не описывают измерение, внедрение или результат в production.
\nНовая команда просит создать внутренний сервис и получает знакомый совет: скопируйте соседний репозиторий, замените имя, остальное поправьте после запуска. Сначала это выглядит дешевле платформенного шаблона. Через несколько недель копии расходятся по CI, healthcheck и владельцам конфигурации. Когда меняется общее правило, его приходится искать в каждом репозитории. Review сравнивает несколько версий «правильного» старта, а rollback зависит от того, какая копия успела стать образцом.
\nПроблема не в количестве файлов. Команда потеряла контракт: какие входы обязательны, что именно создаётся, кто владеет правилом и когда генератор должен остановиться. Платформенный шаблон полезен, пока фиксирует повторяемый класс решений. Для известного варианта нужен узкий golden path, для одного контролируемого отклонения — именованное расширение, а неизвестная политика должна уйти в отдельное решение.
\nПервый сигнал — в форму добавляют «ещё одну галочку»: новый runtime, другую базу, особый retention или исключение из CI. Второй — после генерации каждый владелец сервиса правит один и тот же файл вручную. Третий — от шаблона ждут синхронизации уже созданного репозитория с исходным. Эти просьбы выглядят разными, но у них один источник: стартовый набор файлов перепутали с архитектурным договором.
\nПроверка должна быть предметной. Для каждого поля назовите его owner, одинаковый смысл для заявленного класса и обратимое действие при ошибке. Если значение зависит от конкретной команды или ещё не утверждённой политики, поле не принадлежит base template. Если после создания результат нужно регулярно подтягивать из общего источника, это уже задача управления зависимостью или upstream-процесса, а не простого копирования.
\nУ рабочего шаблона есть небольшой закрытый договор. Он описывает идентификатор и версию, целевой тип компонента, обязательные входы, создаваемые артефакты и явные отказы. Например, для внутреннего HTTP-сервиса обязательными входами могут быть имя сервиса, владелец компонента, одобренный класс runtime и класс данных. Выходом будет skeleton репозитория, черновик метаданных каталога и checklist review.
\nОтрицательная часть договора не менее важна. Шаблон не выдаёт production approval, не создаёт секреты по своему усмотрению, не выбирает новую retention policy и не меняет права доступа. Положительный результат проверки означает только «вход подходит для этого стартового пути». Он не означает, что сервис безопасен, выдержит нагрузку или готов к выпуску.
\n| Часть | Фиксируем | Не прячем в шаблон | Проверка |
|---|---|---|---|
| Идентичность | Имя сервиса, владелец компонента, версия шаблона | Будущее название команды или продукта | Владелец найден в разрешённом справочнике |
| Класс | Internal HTTP service и approved runtime | Новый runtime ради одной заявки | Класс и runtime входят в поддерживаемый список |
| Данные | Уже утверждённый internal data class | Новая retention или access policy | Ссылка на действующее правило и его owner |
| Выход | Skeleton, metadata draft, checklist review | Production approval, итог CI и разрешение на выпуск | Артефакты проверены отдельным review |
| Отклонение | Именованное расширение с owner и rollback | Безымянный patch в каждом generated repo | Описаны условия включения и удаления |
Такой контракт превращает разговор «давайте сделаем универсально» в проверяемый вопрос. Для каждой строки можно показать источник значения, ответственного и действие при несовпадении. Если это невозможно, форма пока маскирует архитектурную неопределённость.
\nBackstage называет этот механизм Software Templates. Официальная документация описывает загрузку skeleton-кода, подстановку переменных и публикацию результата в такие места, как GitHub или GitLab. Шаблон состоит из шагов, а перед запуском пользователь может проверить введённые параметры на review-странице. Это хороший механизм повторяемого старта, но он не определяет политику конкретной компании сам по себе.
\nПубликация требует настроенных интеграций и разрешений приложения. Поэтому из того, что Backstage умеет вызвать publish action, нельзя делать вывод о наличии прав у каждой команды или о прохождении security review. В вашем экземпляре нужно отдельно проверить, какие actions включены, куда им разрешено писать и какой процесс принимает созданный репозиторий. Версия Backstage также имеет значение: названия полей и доступные actions сверяйте с документацией установленного релиза.
\nВ этой статье слово «расширение» означает локальное соглашение команды, а не встроенный объект Backstage. Оно должно иметь имя, owner, условия входа, список изменяемых артефактов, способ удаления и дату пересмотра. Такое описание оставляет отличие видимым. Без него расширение быстро превращается в ещё одну копию base template.
\nНиже маленькая in-memory-проверка. Она не создаёт репозиторий, не обращается к Backstage, не меняет CI и не проверяет реальные права. Её можно вставить в файл и выполнить Node.js без зависимостей. Ожидаемый вывод — сначала true, затем false: новая политика не должна пройти через тот же путь, что и согласованный internal service.
const contract = {\n required: ['serviceName', 'componentOwner', 'approvedRuntime', 'dataClass'],\n allowedRuntimes: ['node-approved'],\n allowedDataClasses: ['internal'],\n};\n\nfunction accepts(input) {\n const hasRequired = contract.required.every((key) => Boolean(input[key]));\n const runtimeIsAllowed = contract.allowedRuntimes.includes(input.approvedRuntime);\n const dataClassIsAllowed = contract.allowedDataClasses.includes(input.dataClass);\n const hasNewPolicy = Boolean(input.newRetentionPolicy);\n return hasRequired && runtimeIsAllowed && dataClassIsAllowed && !hasNewPolicy;\n}\n\nconst knownRequest = {\n serviceName: 'billing-api',\n componentOwner: 'billing-team',\n approvedRuntime: 'node-approved',\n dataClass: 'internal',\n};\n\nconsole.log(accepts(knownRequest));\nconsole.log(accepts({ ...knownRequest, newRetentionPolicy: '90-days' }));\nСкопируйте блок в файл contract-check.js и запустите командой node contract-check.js. Вторая строка должна быть false. В реальном шаблоне проверка должна использовать справочники и policy вашей платформы; строка node-approved здесь учебная и не означает, что такой runtime существует у вас.
Узкий путь не означает бедный путь. В него можно включить структуру документации, обязательные labels, регистрацию компонента, базовый health endpoint и согласованный delivery-маршрут. Ограничение относится к смыслу выбора: каждое поле должно иметь одно и то же значение для всего класса. Поле «выберите любую базу» нарушает это правило, если базы меняют отказоустойчивость, хранение данных и эксплуатационные обязанности.
\nУ каждого правила должен быть ответ на вопрос «кто изменит его, если оно устареет?». Для шаблона это может быть команда платформы, для data policy — владелец данных, для каталожных метаданных — отдельный owner. Число успешных запусков не доказывает правильность договора. Результат нужно сверить с созданными файлами, metadata, правами и обязательными проверками.
\nОтклонение допустимо как расширение, если базовый класс не меняется. Например, сервис остаётся внутренним HTTP-сервисом, сохраняет approved runtime и data class, но подключает заранее определённый observability adapter. У extension должны быть имя, owner, входное условие, граница изменений и rollback. Его подключение должно быть видно в результате генерации и в review, а не существовать только в памяти автора.
\nЕсли расширение меняет класс данных, доступ, retention или право на выпуск, это уже не «одна дополнительная настройка». Такой вариант нужно вынести в собственный контракт с отдельными проверками. Если одинаковый локальный patch повторился несколько раз, соберите факты: совпадает ли класс задач, одинаковы ли условия и можно ли удалить изменение без ручного поиска по всем копиям. Только после этого решайте, стал ли patch расширением или отдельным шаблоном.
\nУ GitHub есть важное ограничение, которое легко потерять в рекламном описании шаблона. Документация GitHub указывает, что ветви репозитория, созданного из template, имеют несвязанную историю. Поэтому между ветвями нельзя строить обычные pull request или merge. В отличие от этого fork сохраняет историю родительского репозитория.
\nСледствие прикладное: template repository подходит, чтобы быстро начать независимый проект с одинаковой структурой. Он не обещает, что исправление в исходном шаблоне автоматически придёт во все созданные репозитории. Если общий код должен обновляться централизованно, используйте зависимость, пакет, generator с явным upgrade-процессом или другой механизм с проверяемой связью. Выбирайте его по требованию к жизненному циклу, а не по похожему начальному набору файлов.
\nРазовая миграция регулируемых данных обычно не подходит для общего service template. В ней могут быть неизвестны владелец данных и lifecycle, а access и retention требуют отдельного согласования. Если спрятать эти вопросы в форму, пользователь получит аккуратный skeleton, но архитектурная ответственность останется неразрешённой.
\nОтказ возвращает задачу в правильную границу: design record, security review или обсуждение владельца данных. После нескольких одинаковых решений может появиться новый класс. Тогда его оформляют отдельно — с собственным owner, входами, инвариантами, источниками и rollback. Одной удачной заявкой такой класс не доказывается.
\nПлатформенный шаблон не заменяет threat modeling, capacity planning, тесты, CI, миграционный план и проверку прав. Он не гарантирует качество созданного сервиса и не откатывает изменения, внесённые командой после генерации. Для draft можно удалить draft; для extension нужен описанный способ отключения; для уже созданного репозитория отдельно определяют, возвращаются ли файлы, зависимость, конфигурация или данные.
\nШаблон готов к применению, если второй инженер без устных пояснений может показать класс, owner, версию, обязательные входы, инварианты, output, refusal criterion и rollback. Для каждой интеграции нужно дополнительно проверить установленную версию Backstage, разрешения publish action, целевое хранилище и правила вашей организации. Учебная команда выше подтверждает только отрицательную ветку в памяти Node.js; она не подтверждает живой scaffolder или production readiness.
\nСимптом обычно появляется после deploy: новая версия сервиса читает новое поле, а часть записей всё ещё хранит старую форму. Затем backfill начинает нагружать базу, а старый worker продолжает писать только старое представление. В логах растёт доля fallback-чтений, обработка очереди замедляется, а команда уже обсуждает удаление старой колонки. Цена ошибки — не только откат релиза. Код можно вернуть, но уже записанные данные не обязаны вернуться в прежнюю форму. Пользователь увидит пустое значение, а восстановление потребует отдельного data repair.
\nТезис простой: миграция данных — это не одна команда DDL и не один зелёный deploy. Сначала нужно сохранить совместимость версий, затем ограниченно перенести данные, после этого доказать готовность нового чтения и только в конце удалить старую форму. Каждый переход должен иметь собственную проверку. Если хотя бы один потребитель неизвестен, старое представление остаётся.
\nВ старой системе заказ хранится в полях status и amount. Новая версия хочет хранить объект summary. На первом шаге схема получает новую форму, но старый writer не должен ломаться. Новый reader принимает обе формы. Новый writer временно записывает обе. Это expand.
На втором шаге backfill обрабатывает старые записи. Он не должен проходить по таблице без границы. Нужны область работы, размер порции, владелец, идемпотентность и заранее определённый сигнал остановки. Это migrate. Важен не сам факт запуска job, а понятный результат частичного выполнения: какие записи обработаны и что произойдёт после остановки.
\nЗатем система переключает чтение на новую форму. Fallback к старой форме ещё нужен, пока не проверены старые записи, отложенные worker-ы и все читатели. Успешное чтение новой записи не доказывает, что старых потребителей больше нет. Switch опирается на наблюдаемые данные, а не на дату релиза.
\nContract — отдельное решение. Старую форму можно удалить только после подтверждения, что старый reader и writer больше не участвуют, backfill завершён с понятным критерием, а восстановление не зависит от удаляемых данных. Если условие не доказано, contract откладывают. Это отрицательный путь, а не неполная миграция.
\nНиже — ограниченный пример на JavaScript. Он проверяет только заявленные версии и формы. Функция не обращается к базе, не запускает SQL и не измеряет нагрузку. Поэтому результат stop означает «не переходить к следующему этапу в этом сценарии», а не verdict для production.
const contract = {\n oldReader: true,\n oldWriter: true,\n newReaderAcceptsOld: true,\n newReaderAcceptsNew: true,\n newWriterWritesBoth: true,\n backfillHasStop: false,\n oldConsumersFound: true,\n};\n\nfunction decideMigration(state) {\n const compatible =\n state.oldReader &&\n state.oldWriter &&\n state.newReaderAcceptsOld &&\n state.newReaderAcceptsNew &&\n state.newWriterWritesBoth;\n\n if (!compatible) {\n return { phase: 'expand', action: 'stop', reason: 'version mismatch' };\n }\n\n if (!state.backfillHasStop) {\n return { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' };\n }\n\n if (state.oldConsumersFound) {\n return { phase: 'contract', action: 'stop', reason: 'old consumer remains' };\n }\n\n return { phase: 'contract', action: 'review', reason: 'evidence required' };\n}\n\nconsole.log(decideMigration(contract));\n// { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' }\nКод защищает порядок рассуждения. Он сначала проверяет совместимость, потом наличие stop condition, затем старых потребителей. В production эти признаки получают из реестра версий, логов, метрик, запросов к данным и согласованного runbook. Нельзя заменить их булевыми значениями из фикстуры. Учебный результат ограничен демонстрацией ветвления.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новая форма есть только у части записей | Backfill ещё не закончен или новые записи обходят dual write | Сравнить доли old/new по времени записи и источнику | Оставить fallback, остановить contract, исправить writer |
| Backfill замедляет рабочие запросы | Широкий scope, слишком большая порция или конкурирующая нагрузка | Проверить latency, lock wait, размер batch и границу выборки | Остановить job, сузить scope и определить лимит до нового запуска |
| Старая версия получает ошибку записи | Schema constraint введён раньше совместимого writer | Воспроизвести запись v1 на тестовой копии и проверить порядок deploy | Вернуть совместимое расширение, не маскировать ошибку retry |
| После deploy растёт fallback | Reader видит old data или новый writer не заполнил поле | Разделить fallback по версии, endpoint и типу записи | Сохранить старую ветку и найти источник несовместимых записей |
| Все тесты зелёные, но consumer неизвестен | Тест проверяет сценарий, а не весь fleet | Сверить владельцев, worker-ы, cron, batch и старые clients | Не удалять старую форму до найденного доказательства |
| Нужен срочный rollback после очистки | Удаление данных ошибочно назвали обратимым | Проверить backup, retention и возможность read-back старой формы | Перейти к data repair или restore-плану, не обещать обычный rollback |
Иллюстрация полезна именно как граница ответственности. Совместимость версий проверяет контракт приложения. Backfill проверяет состояние данных и нагрузку. Contract проверяет отсутствие зависимости от старой формы. Ни один этап не доказывает остальные.
\nExpand/contract не делает миграцию беспростойной. Dual write может дать расхождение, если запись в одну систему прошла, а в другую нет. Backfill может конкурировать с индексами, блокировками и репликацией. Внешний клиент может использовать старое поле без регистрации. ORM может добавить собственный cache или изменить порядок чтения. Эти случаи требуют проверки конкретной системы.
\nНе переносите синтаксис PostgreSQL на другую СУБД. Даже в PostgreSQL команда, которая добавила constraint, не равна доказательству, что все старые строки уже проверены. Не считайте зелёный тест доказательством надёжности. Не увеличивайте batch, если неизвестна причина нагрузки. Не запускайте contract после одного удачного прогона. Если обнаружили несовместимость, правильное действие — остановить переход и сохранить старую форму.
\nМиграция готова к contract только тогда, когда одновременно выполнены пять условий: все допустимые версии читают нужную форму; writer-ы не создают неподдерживаемые записи; backfill имеет завершённый scope и повторяемый результат; использование old representation и fallback равно нулю в согласованном окне наблюдения; recovery-план проверен для оставшейся границы риска. Число и длительность окна должны определить владельцы системы по своим SLO и traffic profile. В этой статье они не выдумываются.
\nЕсли хотя бы одно условие нельзя подтвердить, критерий не выполнен. Это не повод скрыть расхождение за словом «почти». Оставьте старую форму, остановите удаление и соберите недостающее evidence. Такой отказ дешевле восстановления данных после необратимого contract.
\nNOT VALID и VALIDATE CONSTRAINT. Детали относятся к PostgreSQL и требуют проверки версии и конфигурации.Симптом появляется сразу после deploy: новая версия сервиса читает поле summary, а старый worker продолжает писать только status и amount. Часть строк уже имеет новую форму, часть — нет. Затем backfill начинает конкурировать с рабочими запросами, и кто-то предлагает удалить старую колонку, чтобы «закрыть миграцию». Это не один дефект схемы, а смешение трёх состояний: код ещё совместим не со всеми данными, перенос не имеет управляемой границы, а очистку принимают за обратимую операцию.
Разберём типичный полевой кейс, а не будем выдавать его за журнал конкретного инцидента. Главный вопрос такой: какие доказательства нужны до переключения чтения и до удаления старой формы? Ответ практический: сначала сохранить совместимость версий, затем перенести ограниченную область данных, потом проверить новый путь и только после этого принимать отдельное решение о contract. Если неизвестен хотя бы один старый потребитель, правильное действие — остановиться.
\nПредставим таблицу orders. До изменения приложение хранит короткий итог заказа в двух колонках:
status = 'paid'\namount = 125000\nНовая версия хочет единый объект summary, например { status: 'paid', amountCents: 125000 }. Схему расширили, но старый сервис не знает о новом поле. Если новая колонка сразу станет обязательной, старый writer начнёт получать отказ. Если сделать её nullable и сразу переключить reader, старые записи могут превратиться в пустой экран. Если запустить backfill без границы, проблема совместимости сменится проблемой нагрузки.
Первое действие — составить список участников. В него входят HTTP-сервисы, workers, cron-задачи, импортёры, отчёты и внешние клиенты. Для каждого нужно записать версию, что он читает и что пишет. «Мы нашли все вызовы» — гипотеза, пока её не сверили с кодом, расписаниями, очередями и логами. Один забытый worker делает ранний contract опасным даже при зелёных тестах основного сервиса.
\nExpand — это изменение, после которого допустимые старые и новые версии ещё могут работать одновременно. Новая колонка или таблица появляются без требования, которое отвергнет старый writer. Новый reader понимает обе формы. Новый writer на переходный период записывает обе формы либо пишет новую форму так, что старый reader остаётся работоспособным.
\nСлово «nullable» описывает только одно свойство схемы. Оно не отвечает, что означает отсутствие значения: запись ещё не перенесена, поле к этому заказу неприменимо или преобразование потеряло данные. Эти три случая нельзя смешивать одним NULL. Если смысл отсутствия не определён, reader будет вынужден угадывать, а fallback превратится в скрытый источник расхождений.
Для PostgreSQL есть отдельная полезная граница. В документации PostgreSQL 16 указано, что constraint можно добавить как NOT VALID: существующие строки не сканируются сразу, но новые вставки и обновления уже проверяются. Позже VALIDATE CONSTRAINT сканирует таблицу и подтверждает условие; команда получает SHARE UPDATE EXCLUSIVE lock. Это пример различия между «правило добавлено» и «все старые строки ему соответствуют». Он относится к PostgreSQL 16 и не переносит поведение на MySQL, другую версию, ORM или managed service.
Матрица помогает обнаружить несовместимую пару до запуска данных. В ней нет попытки описать весь deployment: это минимальный контракт конкретного поля.
\n| Участник | Читает old | Читает new | Пишет old | Пишет new | Что проверить |
|---|---|---|---|---|---|
| v1 reader | да | нет | — | — | new не должен стать единственным источником до удаления v1 |
| v1 writer | — | — | да | нет | expand не должен отвергать запись без summary |
| v2 reader | да | да | — | — | fallback считать и разделять по версии |
| v2 writer | — | — | да | да | расхождение двух записей должно иметь понятный recovery |
| contract | нет | да | нет | да | old consumer и old representation больше не нужны |
Таблица показывает важное ограничение: успешный v2 reader не доказывает отсутствие v1 writer. Аналогично, заполненные новые строки не доказывают, что отложенная очередь или внешний импортёр перестали писать старую форму. Поэтому переходы reader, writer и schema cleanup не стоит объединять в один release.
\nBackfill — это рабочий процесс, который читает старые строки и создаёт для них новую форму. У него должны быть scope, размер порции, порядок обхода, повторный запуск, владелец и stop condition. Scope отвечает на вопрос «какие строки мы переносим», stop condition — «когда прекращаем работу даже при неполном результате». Без второго параметра команда не умеет безопасно остановиться: при росте latency остаётся только спорить, продолжать ли job.
\nДля большой таблицы полезен ключ-граница, а не неопределённое «обработать всё». Например, один запуск может работать с диапазоном идентификаторов до заранее записанного upperBound. Следующий запуск берёт новый диапазон после проверки результата. Но сам диапазон не гарантирует безопасность: нужно измерить lock wait, время запроса, lag реплики, ошибки и долю расхождений в выбранной среде. Порог нельзя взять из этой статьи, потому что его задают размер таблицы, индексы, traffic profile и SLO конкретной системы.
GitLab в официальном руководстве разделяет schema migration и batched background migration: большие изменения данных выносятся в пакетную фоновую обработку, а схема меняется отдельно. Это полезное правило организации работ, но не готовая команда для чужого стека. Для PostgreSQL, Rails, очереди и версии GitLab нужны собственные ограничения и проверки.
\nНиже — самостоятельный Node.js-скрипт. Он не подключается к базе: в массиве зафиксированы три записи, одна из которых уже имеет summary. Скрипт считает, что запись с новой формой можно пропустить, а старую — преобразовать. Если в отчёте остались ошибки преобразования или не задана граница диапазона, он возвращает stop. Это пример проверки структуры плана, не измерение производительности и не разрешение на production backfill.
const records = [\n { id: 101, status: 'paid', amount: 125000, summary: null },\n { id: 102, status: 'cancelled', amount: 9900, summary: { status: 'cancelled', amountCents: 9900 } },\n { id: 103, status: 'paid', amount: 0, summary: null },\n];\n\nconst plan = { upperBound: 103, batchSize: 2, stopOnError: true };\n\nfunction toSummary(record) {\n if (!Number.isInteger(record.amount) || record.amount < 0) {\n return { ok: false, reason: 'amount-is-not-a-non-negative-integer' };\n }\n\n return {\n ok: true,\n value: { status: record.status, amountCents: record.amount },\n };\n}\n\nfunction inspectBackfill(rows, migrationPlan) {\n if (!Number.isInteger(migrationPlan.upperBound) || migrationPlan.batchSize < 1) {\n return { action: 'stop', reason: 'bounded-plan-is-missing' };\n }\n\n const candidates = rows.filter((row) => row.id <= migrationPlan.upperBound);\n const results = candidates.map((row) => {\n if (row.summary !== null) return { id: row.id, action: 'skip' };\n const converted = toSummary(row);\n return converted.ok\n ? { id: row.id, action: 'backfill', summary: converted.value }\n : { id: row.id, action: 'error', reason: converted.reason };\n });\n\n const errors = results.filter((result) => result.action === 'error');\n if (errors.length && migrationPlan.stopOnError) {\n return { action: 'stop', reason: 'conversion-error', errors };\n }\n\n return { action: 'review', upperBound: migrationPlan.upperBound, results };\n}\n\nconsole.log(JSON.stringify(inspectBackfill(records, plan), null, 2));\n// action: review; запись id=103 попадёт в backfill\n// В production здесь нужны транзакция, retry policy и запись прогресса.\nЧтобы повторить пример, сохраните блок в файл migration-check.mjs и выполните node migration-check.mjs. Результат review означает только то, что три синтетические строки прошли эту проверку. Скрипт не знает о конкурентной записи, транзакционной границе, репликации, правах, шифровании и нагрузке. Эти вопросы нельзя «дописать» одним boolean-полем.
После backfill читатель можно переключать постепенно. Сначала v2 умеет прочитать old и new, затем для выбранной области сравниваются результаты двух представлений. Расхождение нужно считать отдельно от обычной ошибки запроса: иначе fallback будет выглядеть как успешный ответ. Полезные поля наблюдения — версия reader, endpoint, идентификатор операции, причина fallback и направление записи. Чувствительные значения в лог не попадают.
\nНельзя заменить evidence календарём. Для переключения нужны хотя бы завершённый scope выбранной области, понятная доля fallback, отсутствие новых ошибок преобразования и подтверждённый список старых writers. Окно наблюдения и численные пороги определяет владелец сервиса по своему SLO. В этом тексте нет выдуманного «24 часа без ошибок»: для одной системы это может быть мало, для другой — не иметь смысла из-за недельной периодичности batch.
\nGoogle SRE Book формулирует границу шире: тесты проверяют конкретные области эквивалентности и уменьшают неопределённость после изменения, но зелёный тест не доказывает надёжность всей системы. Поэтому rehearsal и тесты сравнения — аргументы для switch, а не автоматический приказ удалить старую форму.
\nContract начинается тогда, когда старое представление больше не нужно ни одному допустимому читателю и writer-у. До этого момента его можно сделать невидимым для нового пути, но не следует физически удалять. Сначала прекращают старые записи, затем подтверждают отсутствие old consumer, потом удаляют код fallback и только после этого рассматривают удаление колонки или таблицы. На каждом шаге нужен read-back состояния.
\nRollback к прежнему binary возвращает код, а не обязательно данные. Если contract уже удалил колонку, очистил старую таблицу или преобразовал значение с потерей информации, восстановление потребует data repair или restore из резервной копии. Поэтому в runbook полезно разделить три границы: что откатывает deploy, что восстанавливает application path и что требует восстановления данных. Слово «rollback» без этого разделения создаёт ложное чувство безопасности.
\nExpand/migrate/switch/contract не решает автоматически dual write между двумя базами. Если запись в одну систему подтверждена, а во вторую нет, потребуется reconciliation или другая архитектура согласованности. ORM может кэшировать старую форму. Реплика может отставать. Очередь может повторить сообщение. Внешний клиент может использовать старое поле без регистрации. Для каждого случая нужно отдельно определить идемпотентность, порядок событий и источник истины.
\nПример SQL из документации PostgreSQL нельзя переносить на другую СУБД. Даже в PostgreSQL NOT VALID не означает, что старые строки уже соответствуют constraint; это состояние до отдельной валидации. Материал Stripe — описание миграции Stripe с их хранилищем и инструментами, а не обещание нулевого простоя в вашем проекте. Источник Google объясняет роль тестов, но не вычисляет допустимый batch или capacity базы.
Если наблюдение показывает рост lock wait, lag, ошибок преобразования или divergence, остановка — ожидаемый результат контроля. Сначала сохраняют старую форму, фиксируют частичный прогресс и выясняют причину. Увеличить параллелизм или удалить fallback без этой проверки значит расширить blast radius, а не ускорить завершение.
\nContract можно рассматривать только при одновременном выполнении пяти условий: все разрешённые версии читают совместимую форму; writer-ы не создают неподдерживаемые строки; backfill завершил явно заданный scope и даёт повторяемый результат; fallback и divergence наблюдаются в согласованном окне с результатом, который устраивает владельца; recovery boundary проверена для каждого необратимого шага.
\nЭто не универсальный чек-лист допуска. Владелец конкретной системы должен добавить версию СУБД, размер и форму данных, ограничения прав, репликацию, расписание редких consumers и критерии SLO. Но логика останется той же: если доказательство отсутствует, старое представление не удаляют. Возврат к совместимому коду дешевле, чем восстановление информации, которую уже физически стерли.
\nNOT VALID, последующей VALIDATE CONSTRAINT, проверок существующих строк и lock level. Синтаксис и поведение ограничены PostgreSQL 16.После релиза новая версия сервиса получает записи без нового поля. В логах растёт число ошибок валидации, а старый worker продолжает записывать прежнюю форму. Другой симптом выглядит тише: backfill работает, но вместе с ним растут задержки обычных запросов. Остановка оставляет неизвестное число частично обработанных записей.
Цена ошибки — не только несколько 500. Смешанные версии могут по-разному прочитать одну запись. Повторная попытка может создать расхождение. Откат бинарника не вернёт удалённое поле или старое значение. Если команда не знает, какие записи уже изменились, она теряет безопасную границу восстановления.
Схема базы — общий протокол между версиями приложения. Поэтому изменение нужно проверять для четырёх ролей: old reader, old writer, new reader и new writer. Пока две версии могут одновременно обслуживать запросы, новая схема обязана принимать допустимую старую форму. Новая версия должна уметь читать обе формы, если backfill ещё не закончен.
Надёжный маршрут разделяет четыре события: expand добавляет новую поверхность, migrate приводит старые записи к новой форме, switch переводит чтение и запись, contract удаляет старый путь. Эти этапы могут иметь разные владельцы, риски и критерии. Их нельзя прятать в одну миграцию, один релиз или одну фразу «схема уже готова».
Представим запись заказа. Старая форма хранит сумму в поле amount, новая — объект money с суммой и валютой. В transition-периоде новая версия читает обе формы. Новый writer сохраняет обе формы, пока старый reader ещё возможен. Backfill заполняет money только для записей, где значение можно вывести без потери смысла.
function readAmount(order) {\n if (order.money && Number.isFinite(order.money.value)) {\n return { value: order.money.value, currency: order.money.currency };\n }\n\n if (Number.isFinite(order.amount)) {\n return { value: order.amount, currency: 'RUB' };\n }\n\n return { ok: false, reason: 'amount-is-not-recoverable' };\n}\n\nfunction writeOrder(order, money) {\n return {\n ...order,\n amount: money.value,\n money: { value: money.value, currency: money.currency },\n };\n}Это учебный пример. Он не подключается к базе, не проверяет валюту по справочнику, не знает транзакционную границу и не доказывает, что dual write атомарен. Его задача уже: явно показать fallback и отрицательный путь. Если старое поле не позволяет однозначно восстановить валюту, функция должна остановиться, а не записать правдоподобное значение.
В production-протоколе нужно дополнительно определить семантику отсутствующего поля. null может означать «ещё не обработано», «значение неприменимо» или «данные потеряны». Эти состояния нельзя различать по догадке. Их фиксируют в контракте до начала backfill.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новый reader получает записи без нового поля | Backfill не завершён или old writer ещё активен | Сопоставить версию writer, долю старой формы и область backfill | Оставить fallback, остановить contract и уточнить owner перехода |
| Старый writer получает отказ после expand | Схема стала обязательной раньше rollout кода | Проверить запросы old writer и правила default/constraint | Вернуть совместимое правило или остановить выкладку |
| После запуска backfill растёт p95 | Фоновая работа конкурирует за CPU, I/O, lock или соединения | Сравнить окно backfill с latency, saturation и ожиданием ресурсов | Остановить работу по заранее заданному signal и сохранить partial state |
| Данные в старой и новой форме расходятся | Dual write не покрывает путь обновления или повторяется неидемпотентно | Сравнить write paths, ключ операции и правило повторного запуска | Заморозить switch, определить источник истины и исправить расхождение |
| Новая версия зелёная, старый consumer ещё жив | Готовность оценили по одному deployment | Проверить workers, очереди, cron и внешних потребителей | Не удалять старую форму; продлить совместимый период |
| Rollback кода прошёл, данные не читаются | Откат приложения ошибочно приняли за recovery данных | Проверить форму записей, уже удалённые поля и recovery boundary | Перейти к data repair или restore-процедуре, если она предусмотрена |
Таблица задаёт направление расследования, а не готовую причину. Один симптом может иметь несколько источников. Каждое действие должно ссылаться на конкретный signal и менять только одну переменную, иначе результат нельзя интерпретировать.
На expand создают колонку, таблицу или индекс, который не требует от старого кода неизвестного значения. Новая поверхность может быть nullable, но nullable не означает безопасно. Нужно описать, что значит отсутствие, какие записи допустимы и когда значение станет обязательным. Если old writer не способен сохранить новый инвариант, его нельзя превратить в ошибку одним DDL-шагом.
Стоимость операции зависит от движка, версии и объекта. В документации PostgreSQL описаны разные последствия для добавления колонки, default и ограничений. Поэтому нельзя переносить обещание «без блокировки» с одной СУБД на другую. Перед запуском проверяют конкретную операцию на выбранной версии движка и учитывают её влияние на размер таблицы, lock и репликацию.
Backfill — отдельный поток изменения состояния. Для него нужны область записей, размер управляемой порции, правило повторного запуска, owner, журнал результата и условие остановки. «Запустить джобу до конца» не является планом. Конец может не наступить, а повторный запуск может дважды применить преобразование.
Стоп-сигнал должен быть наблюдаемым: превышение согласованной задержки, рост ошибок, lock wait, нарушение контрольной выборки или явная команда владельца. Значения порции и пороги нельзя брать из этого учебного текста. Их получают для конкретной среды. Учебный код не читает метрики и не даёт производственных результатов.
Остановка не означает провал. Она должна оставить состояние, которое можно описать: какие записи обработаны, какие пропущены, что делает следующий запуск и сохраняется ли старая форма. Если после stop никто не может ответить на эти вопросы, backfill не готов к запуску.
Switch переводит reader на новую форму. До него проверяют, что новый reader понимает старую запись, новая запись имеет ожидаемую семантику, а divergence можно обнаружить. После switch fallback ещё может оставаться. Факт, что известная выборка заполнена, не доказывает отсутствие старого writer-а в очереди, worker-е или внешнем consumer-е.
Contract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это необратимее обычного rollback. Возврат старого бинарника может вернуть старую логику, но не удалит уже записанные новые значения и не восстановит очищенную историю. Поэтому contract выполняют отдельным решением после доказательства отсутствия declared old reader/writer и после фиксации recovery boundary.
В официальном описании онлайн-миграции Stripe переход разделён на dual write, переключение readers, переключение writers и удаление старых данных. Это полезный шаблон последовательности, но не готовая настройка для другой БД. Сам Stripe отдельно отмечает дополнительную стоимость записи и постепенное увеличение нагрузки. В собственном проекте эти параметры нужно измерять отдельно.
Эта модель не выбирает isolation level, batch size, lock timeout, формат журнала, стратегию репликации или восстановление из backup. Она не решает вопросы PII, retention, шифрования и прав доступа. Dual write может быть неатомарным, если две формы лежат за разными транзакционными границами. ORM, cache, trigger и очередь могут добавить пути, которых нет в основном сервисе.
Если новый инвариант нельзя поддержать для old writer, не пытайтесь ускорить rollout. Оставьте expand совместимым, добавьте адаптер или выберите отдельный период остановки. Если backfill нельзя bounded-ить, не запускайте его «на пробу». Если не найден old consumer, не удаляйте старую форму. Если уже произошла потеря данных, называйте действие recovery или data repair, а не rollback.
Миграция готова к следующему этапу, когда документированный owner может проверить пять фактов: каждая активная версия имеет описанные read/write-пары; old writer не отвергается; backfill имеет повторяемость, границу и stop signal; divergence обнаруживается; contract имеет отдельный recovery boundary. Для финального удаления дополнительно нужно подтверждение, что старое представление больше не требуется ни одному заявленному consumer-у.
Учебный пример выше можно проверить на четырёх входах: новая форма, старая форма, неполная старая форма и конфликтующие значения. Ожидаемый результат — корректное чтение первых двух и явный отказ последних двух. Эта проверка подтверждает логику функции, но не поведение базы, нагрузку, deployment или полноту production-данных.
После релиза новая версия сервиса получает записи без нового поля, а старый worker продолжает записывать прежнюю форму. В логах растут ошибки валидации. Другой симптом тише: backfill заполняет данные, но вместе с ним увеличивается задержка обычных запросов. Остановить задачу можно, однако без журнала непонятно, какие записи уже изменились.
Цена ошибки — не только несколько ответов 500. Две версии могут по-разному прочитать одну запись, повторная попытка — создать расхождение, а откат бинарника не вернёт удалённое поле или старое значение. Главный вопрос миграции звучит так: какую форму данных каждая активная версия умеет читать и писать на каждом этапе?
Схема базы — общий протокол между версиями приложения. Поэтому до DDL нужно описать четыре роли: старый reader, старый writer, новый reader и новый writer. Пока версии работают одновременно, новая схема должна принимать допустимую старую запись, а новый reader — понимать старую форму, если заполнение ещё не закончено.
Для примера возьмём заказ. Старая форма хранит целое число копеек в amount. Новая форма хранит объект money с копейками и кодом валюты. Это не универсальная модель денег: она лишь делает явным, что старое поле можно преобразовать в рубли только при заранее известном договоре. Если валюта старых записей неизвестна, подставлять RUB нельзя.
Expand добавляет новую поверхность: колонку, таблицу или индекс. Старый код после этого всё ещё должен работать. Backfill переносит уже существующие записи ограниченными порциями. Switch меняет основной путь чтения, а затем, при необходимости, записи. Contract удаляет старый путь и старые данные. У каждого этапа свой сигнал готовности и свой способ остановки.
Эта последовательность не обещает нулевой риск. Она уменьшает размер изменения, оставляет совместимый путь и позволяет остановиться до необратимого удаления. Stripe описывает похожую схему для большой миграции: двойная запись, проверка чтения, перевод writers и удаление прежней модели. Это полезный разбор конкретной системы, а не готовая инструкция для любого хранилища.
Переходный reader должен сначала проверять новую форму, затем старую. Если обе формы присутствуют, он обязан заметить конфликт, а не молча выбрать одну. В примере ниже сумма задана в минимальных единицах, поэтому сравнение числовых значений не зависит от форматирования.
function readMoney(order) {\n const hasNew = order.money !== null &&\n typeof order.money === 'object' &&\n Number.isSafeInteger(order.money.value) &&\n typeof order.money.currency === 'string';\n const hasOld = Number.isSafeInteger(order.amount);\n\n if (hasNew && hasOld && order.money.value !== order.amount) {\n return { ok: false, reason: 'representations-conflict' };\n }\n if (hasNew) return { ok: true, value: order.money.value, currency: order.money.currency };\n if (hasOld) return { ok: true, value: order.amount, currency: 'RUB', source: 'legacy' };\n return { ok: false, reason: 'money-is-not-recoverable' };\n}\n\nfunction writeMoney(order, money) {\n if (!Number.isSafeInteger(money.value) || typeof money.currency !== 'string') {\n throw new Error('invalid-money');\n }\n return {\n ...order,\n amount: money.value,\n money: { value: money.value, currency: money.currency },\n };\n}Функция проверяет форму и конфликт, но не доказывает атомарность сохранения. В реальном сервисе список валют должен приходить из контракта или справочника, а обе записи должны попадать в одну транзакцию либо иметь документированный механизм восстановления. Если writer-ы работают через очередь или разные хранилища, одной такой функции недостаточно.
Ниже — небольшой PostgreSQL-способ проверить идею bounded backfill. Он предполагает, что amount — копейки в рублях, money — jsonb, а id монотонно упорядочивает порции. Каждая порция выполняется отдельной транзакцией и повторно выбирает только строки с пустым новым полем.
BEGIN;\n\nWITH batch AS (\n SELECT id, amount\n FROM orders\n WHERE money IS NULL AND amount IS NOT NULL\n ORDER BY id\n LIMIT 100\n FOR UPDATE SKIP LOCKED\n)\nUPDATE orders AS o\nSET money = jsonb_build_object('value', batch.amount, 'currency', 'RUB')\nFROM batch\nWHERE o.id = batch.id\nRETURNING o.id, o.money;\n\nCOMMIT;\n\nSELECT\n count(*) FILTER (WHERE money IS NULL AND amount IS NOT NULL) AS pending,\n count(*) FILTER (WHERE money IS NOT NULL) AS filled\nFROM orders;LIMIT 100 — не рекомендация для production, а воспроизводимая граница примера. Её подбирают по времени транзакции, lock wait, нагрузке на I/O и p95 обычных запросов. SKIP LOCKED позволяет не ждать уже захваченные строки, но может временно пропускать их; поэтому повторный запуск и итоговая проверка обязательны. Если в рабочем writer-е нет той же блокировки или транзакционной границы, backfill всё равно может пересечься с изменением записи.
| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| Новый reader видит старую форму | Backfill не завершён или старый writer ещё активен | Сопоставить версии writers, долю старых записей и scope задачи | Оставить fallback и не начинать contract |
| Старый writer получает отказ после expand | Новое поле сделали обязательным слишком рано | Проверить DDL, default, constraint и фактический запрос | Вернуть совместимое правило или остановить rollout |
| Во время backfill вырос p95 | Задача конкурирует за CPU, I/O, locks или соединения | Сравнить окно задачи с latency, saturation и lock wait | Остановить по заранее названному порогу и сохранить partial state |
| Старое и новое значения расходятся | Пропущен write path или повтор неидемпотентен | Сравнить все пути записи и ключ повторной операции | Заморозить switch и определить источник истины |
| Новая версия зелёная, старый consumer жив | Проверили deployment, но не worker, cron или очередь | Собрать inventory активных consumers и их версий | Продлить совместимый период |
| Откат кода прошёл, данные не читаются | Rollback приложения приняли за восстановление данных | Проверить уже удалённые поля и recovery boundary | Запустить отдельный data repair или restore-процедуру |
Таблица задаёт порядок расследования, но не устанавливает причину автоматически. Сигнал должен быть связан с конкретным действием. Иначе команда одновременно меняет размер порции, версию приложения и настройки базы и теряет возможность понять результат.
Для PostgreSQL безопаснее начать с nullable-колонки без немедленного обязательного значения, когда это соответствует доменной модели:
ALTER TABLE orders ADD COLUMN money jsonb;Операция всё равно требует проверки блокировок и версии СУБД. Документация PostgreSQL отдельно описывает стоимость добавления колонки с постоянным default и случай с volatile default, при котором таблица может быть переписана. Нельзя переносить вывод «быстро и без блокировки» с одной версии, типа default или СУБД на другую. Constraint, который требует заполнения каждой старой строки, добавляют после проверки данных или отдельным совместимым шагом.
До expand полезно выполнить проверку формы и объёма:
SELECT\n count(*) AS total,\n count(*) FILTER (WHERE amount IS NULL) AS missing_amount,\n count(DISTINCT currency) AS currencies\nFROM legacy_order_amounts;Последний запрос применим только если старую валюту действительно хранили в отдельном поле. Если такой колонки нет, это не повод считать все суммы рублями: нужно найти источник валюты или отправить записи в ручной разбор.
У backfill должны быть scope, владелец, размер порции, критерий повторного запуска, журнал обработанных строк и стоп-сигнал. Минимальный журнал фиксирует время, диапазон или набор идентификаторов, количество успешно обработанных и количество пропущенных записей. Без этого остановка превращается в догадку.
Повторяемость не равна идемпотентности. Условие money IS NULL делает показанный пример повторяемым для незаполненных строк, но не решает конфликт, когда старое значение изменилось после первой записи. Для таких строк нужен version check, блокировка, журнал событий или ручная процедура — выбор зависит от writer-а и модели консистентности.
Стоп-сигналом может быть согласованный рост p95, ошибки, lock wait, saturation или расхождение контрольной выборки. Порог получает команда для конкретной среды; в статье нет измерения, из которого его можно вывести. Остановка должна сохранить partial state и ответ на три вопроса: что обработано, что осталось и какой запуск безопасен следующим.
Перед switch проверяют mixed-version сценарий: новый reader читает старую запись, старый reader читает запись после dual write, а конфликт форм обнаруживается. Полезно временно сравнивать результаты старого и нового reader без изменения пользовательского ответа. Такой контроль должен быть безопасен по latency и объёму, а расхождение — попадать в метрику или журнал.
После switch новый reader становится основным, но fallback может оставаться на период наблюдения. Тот факт, что известная выборка заполнена, не доказывает отсутствие старого writer-а в очереди или внешнем consumer-е. Сначала фиксируют inventory и отсутствие divergence, затем принимают отдельное решение о contract.
Contract удаляет колонку, таблицу, fallback, dual write или старый формат. В PostgreSQL DROP COLUMN удаляет данные этой колонки и связанные с ней ограничения. После такого шага возврат старого бинарника не восстановит удалённые значения. Recovery boundary нужно определить до удаления: backup, snapshot, журнал событий или подтверждённый data repair.
Модель подходит для совместимых изменений формы данных, когда старый и новый контракт могут сосуществовать. Она не выбирает isolation level, batch size, lock timeout, стратегию репликации или способ восстановления. Эти решения зависят от СУБД, объёма, нагрузки, SLA и стоимости ошибки.
Dual write может быть неатомарным, если формы лежат за разными транзакционными границами. ORM, cache, trigger, CDC-поток и очередь добавляют пути, которых нет в основном сервисе. PII, retention, шифрование и права доступа требуют отдельной проверки. Если новый инвариант нельзя поддержать для old writer, нужен адаптер или период остановки, а не ускорение rollout.
Если backfill нельзя ограничить порцией и остановить по наблюдаемому сигналу, он не готов к запуску. Если не найден старый consumer, старую форму не удаляют. Если данные уже потеряны, действие называют recovery или data repair, а не rollback.
Следующий этап разрешён, когда владелец может проверить пять фактов: активные версии имеют описанные read/write-пары; expand не отвергает old writer; backfill повторяем и ограничен; расхождение обнаруживается; contract имеет отдельную границу восстановления. Для финального удаления дополнительно нужно подтверждение, что старое представление не требуется ни одному заявленному consumer-у.
После релиза новая версия сервиса отвечает ошибкой на запись: обязательное поле ещё не заполняет старый writer. В другой попытке поле сделали nullable, запустили backfill без предела и получили рост задержек. В обоих случаях DDL прошло успешно. Ошибка появилась позже, когда версии приложения стали жить рядом. Цена ошибки — потерянные записи, очередь повторных запросов и отсутствие честного пути назад. Откат бинарника не возвращает данные, которые уже перезаписаны или удалены.
\nБезопасная миграция — это не одна команда изменения схемы. Это период совместимости между old и new reader, old и new writer, затем отдельное преобразование данных и только потом удаление старой формы. Такой маршрут называют expand–migrate–contract. Он не делает операцию безопасной автоматически. Он раскладывает риск на этапы, для каждого этапа задаёт проверку и оставляет границу, после которой rollback приложения уже недостаточен.
\nПредставьте запись заказа. Старая форма хранит имя клиента в поле customer_name. Новая форма должна хранить ссылку customer_id. Если сразу удалить старое поле, старый сервис перестанет писать. Если сразу потребовать customer_id, старые строки и старые workers станут ошибками. Поэтому сначала добавляют новую поверхность, не запрещая старую.
На этапе expand новая схема должна принимать старую форму. New reader читает обе формы. New writer может записать новую форму и, пока жив старый consumer, сохраняет совместимое старое значение. Backfill переносит уже существующие строки. Только после переключения всех readers и writers появляется основание для contract. У каждого перехода должен быть owner, стоп-сигнал и ответ на вопрос: что сохраняется, если процесс остановить на этой строке?
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старая версия получает отказ после добавления поля. | Expand уже требует new shape. | Проверить SQL-ограничения и запись old writer в отдельной транзакции. | Вернуть обязательность, добавить совместимый nullable-путь и выпустить reader до writer. |
| Backfill перегружает основную базу. | Нет размера партии, лимита скорости и стоп-сигнала. | Сверить нагрузку job с бюджетом обычных запросов и проверить остановку на середине. | Ограничить batch, concurrency и время запуска. Сохранить cursor и повторный запуск. |
| New reader видит пустое значение. | Заполнение приняли за доказательство полноты. | Проверить долю старой формы, правила для null и список ещё живых writers. | Оставить fallback и не переходить к contract. |
| После отката код читает новую схему, но старых данных нет. | Удаление данных назвали rollback. | Спросить, каким действием восстанавливаются удалённые строки и откуда берётся копия. | Остановить contract. Подготовить backup/restore или совместимое представление заранее. |
| Миграция прошла на стенде, а в production неизвестен mixed fleet. | Rehearsal проверила сценарий, но не фактические версии и объём. | Проверить deployment inventory, consumers, lock behavior и размер выборки. | Считать стенд только учебной проверкой. Собрать production evidence до переключения. |
Таблица отделяет наблюдаемый симптом от решения. Если нельзя назвать проверку, команда пока не знает, какой риск она закрывает. Если действие меняет данные, оно должно иметь отдельный журнал, владельца и правило повторного запуска.
\nExpand меняет структуру так, чтобы old code продолжал работать. Это может быть nullable-колонка, новая таблица или новый индекс. Конкретная операция зависит от СУБД. Нельзя переносить обещание «добавление поля быстро» с одной версии и одного типа default на другую. В PostgreSQL поведение ALTER TABLE, блокировки и переписывание таблицы зависят от команды и параметров. Проверяйте документацию именно своей версии.
Для примера с заказом expand добавляет customer_id, но не удаляет customer_name и не делает ссылку обязательной для старых строк. New reader использует customer_id, если он есть, иначе временно читает старое имя. Такой fallback должен иметь owner и условие удаления. Иначе временный путь станет постоянным и команда не поймёт, закончена ли миграция.
function readCustomer(order) {\n if (order.customer_id != null) {\n return { kind: 'id', value: order.customer_id };\n }\n\n if (order.customer_name != null) {\n return { kind: 'legacy-name', value: order.customer_name };\n }\n\n return { kind: 'invalid', value: order.id };\n}\n\n// Учебный пример: не подключается к базе и не доказывает\n// корректность данных в реальном сервисе.\nКод показывает три ветки. Новая форма имеет приоритет. Старая форма остаётся читаемой. Отсутствие обеих форм не превращается в тихий default. В настоящем сервисе проверка должна также учитывать права, конкурентную запись и семантику ошибок. Имена в примере вымышлены.
\nBackfill отвечает на вопрос «как преобразовать старые строки». Он не должен менять договор совместимости. Запускайте его как отдельный процесс с областью, cursor, размером партии, лимитом скорости, idempotency-правилом и измеримым стоп-сигналом. Партия из тысячи строк не является безопасной сама по себе. Она может запускаться без конца, конкурировать с пользовательскими запросами или повторно менять одну строку после сбоя.
\nИдемпотентный шаг проверяет текущее состояние перед записью. Если строка уже имеет корректный customer_id, повторный запуск её пропускает. Если соответствие неоднозначно, job должна остановиться или отправить строку на ручной разбор. Она не должна выбирать первый результат молча. В миграции данных неопределённость — это сигнал остановки, а не повод увеличить batch.
Учебный псевдокод ниже ограничен памятью процесса. Он не читает настоящую БД, не измеряет locks, не запускает транзакции и не сообщает о готовности production.
\nfor (const batch of batches(records, 100)) {\n const updates = [];\n\n for (const record of batch) {\n if (record.customer_id != null) continue;\n\n const match = lookupCustomer(record.customer_name);\n if (match.kind !== 'unique') {\n throw new Error(`stop: ${record.id} needs review`);\n }\n\n updates.push({ id: record.id, customer_id: match.id });\n }\n\n applyUpdates(updates);\n saveCursor(batch.at(-1).id);\n}\nВ примере ошибка останавливает весь учебный проход. В production решение может быть другим: отдельная quarantine-очередь, транзакция на партию или ручное подтверждение. Важно другое: неоднозначная строка не получает случайное значение, cursor сохраняется, а повторный запуск видит уже обработанные записи. Учебный пример не является готовой библиотекой миграции.
\nПереключение чтения не равно завершению backfill. Оно означает, что new reader умеет обработать остаток старой формы и команда согласовала, что делать с null, конфликтом и повторной записью. После переключения наблюдайте ошибки, долю fallback, расхождения двух форм и время обработки. Не удаляйте старое поле сразу после первого зелёного графика.
\nПока old writer или долгоживущий worker ещё может работать, new writer должен сохранять совместимость. Это может быть dual write, событие для отдельного consumer или другой явно описанный механизм. Dual write тоже создаёт риск: записи могут завершиться только в одной форме, а порядок событий может расходиться. Поэтому нужна проверка расхождений и правило исправления. Сам термин dual write ничего не гарантирует.
\nStripe описывает похожий четырёхэтапный путь для своей онлайн-миграции: dual write, перевод чтений, перевод записей и удаление старых данных. Это инженерный разбор инфраструктуры Stripe, а не универсальная гарантия. В другой системе нужно отдельно проверить объём, lock behavior, задержки, ретраи и все пути записи.
\nContract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это полезный финал, но не обычный rollback. Возврат к старому бинарнику восстановит код, а не удалённые значения. Если старое представление нужно для восстановления, его сохраняют до contract: backup проверяют восстановлением, копию снабжают сроком хранения, а divergence связывают с понятным действием.
\nНе называйте contract готовым по одному признаку. Green build не знает о ручном SQL-клиенте. Нулевой fallback за минуту не доказывает, что вчерашний worker завершился. Полная проверка должна охватывать writers, readers, jobs, очереди, отчёты и восстановление. Если список consumers неполон, безопасное действие — продлить совместимый период.
\nExpand–migrate–contract не выбирает isolation level, batch size, lock timeout, retention, backup policy или график запуска. Он не заменяет требования к PII, disaster recovery, capacity planning и проверку прав. PostgreSQL, Stripe и учебный JavaScript-пример описывают разные границы. Их нельзя объединять в обещание zero downtime.
\nДля конкретной миграции критерий готовности проверяем: old и new версии имеют записанный контракт; expand не отвергает old writer; backfill можно остановить и повторить; неоднозначные записи не получают default; список consumers подтверждён; fallback и расхождения наблюдаемы; backup восстановлен на тестовой копии; contract имеет отдельное решение и срок хранения recovery-артефактов.
\nЕсли хотя бы один пункт не подтверждён, миграция не готова к следующему необратимому шагу. Это не провал плана. Это точная граница знания: команда видит, какой факт нужно получить до изменения данных.
\nПосле релиза новая версия сервиса отвечает ошибкой на запись: обязательное поле ещё не заполняет старый writer, то есть компонент, который сохраняет запись. В другой попытке поле сделали nullable, запустили backfill без предела и получили рост задержек. В обоих случаях DDL прошло успешно. Ошибка появилась позже, когда версии приложения стали жить рядом. Цена ошибки — потерянные записи, очередь повторных запросов и отсутствие честного пути назад. Откат бинарника не возвращает данные, которые уже перезаписаны или удалены.
\nБезопасная миграция — это не одна команда изменения схемы. Это период совместимости между старым и новым reader (компонентом чтения), старым и новым writer, затем отдельное преобразование данных и только потом удаление старой формы. Такой маршрут называют expand–migrate–contract. Он не делает операцию безопасной автоматически. Он раскладывает риск на этапы, для каждого этапа задаёт проверку и оставляет границу, после которой rollback приложения уже недостаточен.
\nПредставьте запись заказа. Старая форма хранит имя клиента в поле customer_name. Новая форма должна хранить ссылку customer_id. Если сразу удалить старое поле, старый сервис перестанет писать. Если сразу потребовать customer_id, старые строки и старые workers станут ошибками. Поэтому сначала добавляют новую поверхность, не запрещая старую.
На этапе expand новая схема должна принимать старую форму. New reader читает обе формы. New writer может записать новую форму и, пока жив старый consumer, сохраняет совместимое старое значение. Backfill переносит уже существующие строки. Только после переключения всех readers и writers появляется основание для contract. У каждого перехода должен быть owner, стоп-сигнал и ответ на вопрос: что сохраняется, если процесс остановить на этой строке?
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старая версия получает отказ после добавления поля. | Expand уже требует new shape. | Проверить SQL-ограничения и запись old writer в отдельной транзакции. | Снять обязательность с новой колонки, проверить запись old writer и выпускать reader до writer. |
| Backfill перегружает основную базу. | Нет размера партии, лимита скорости и стоп-сигнала. | Сверить нагрузку job с бюджетом обычных запросов и проверить остановку на середине. | Ограничить batch, concurrency и время запуска. Сохранить cursor и повторный запуск. |
| New reader видит пустое значение. | Заполнение приняли за доказательство полноты. | Проверить долю старой формы, правила для null и список ещё живых writers. | Оставить fallback и не переходить к contract. |
| После отката код читает новую схему, но старых данных нет. | Удаление данных назвали rollback. | Спросить, каким действием восстанавливаются удалённые строки и откуда берётся копия. | Остановить contract. Подготовить backup/restore или совместимое представление заранее. |
| Миграция прошла на стенде, а в production неизвестен mixed fleet. | Rehearsal проверила сценарий, но не фактические версии и объём. | Проверить deployment inventory, consumers, lock behavior и размер выборки. | Считать стенд только учебной проверкой. Собрать production evidence до переключения. |
Таблица отделяет наблюдаемый симптом от решения. Если нельзя назвать проверку, команда пока не знает, какой риск она закрывает. Если действие меняет данные, оно должно иметь отдельный журнал, владельца и правило повторного запуска.
\nExpand меняет структуру так, чтобы old code продолжал работать. Это может быть nullable-колонка, новая таблица или новый индекс. Конкретная операция зависит от СУБД. Нельзя переносить обещание «добавление поля быстро» с одной версии и одного типа default на другую. В PostgreSQL поведение ALTER TABLE, блокировки и переписывание таблицы зависят от команды и параметров. Проверяйте документацию именно своей версии.
Для примера с заказом expand добавляет customer_id, но не удаляет customer_name и не делает ссылку обязательной для старых строк. New reader использует customer_id, если он есть, иначе временно читает старое имя. Такой fallback должен иметь owner и условие удаления. Иначе временный путь станет постоянным и команда не поймёт, закончена ли миграция.
function readCustomer(order) {\n if (order.customer_id != null) {\n return { kind: 'id', value: order.customer_id };\n }\n\n if (order.customer_name != null) {\n return { kind: 'legacy-name', value: order.customer_name };\n }\n\n return { kind: 'invalid', value: order.id };\n}\n\n// Учебный пример: не подключается к базе и не доказывает\n// корректность данных в реальном сервисе.\nКод показывает три ветки. Новая форма имеет приоритет. Старая форма остаётся читаемой. Отсутствие обеих форм не превращается в тихий default. В настоящем сервисе проверка должна также учитывать права, конкурентную запись и семантику ошибок. Имена в примере вымышлены.
\nНиже — самостоятельная демонстрация для временных таблиц. Её можно вставить в psql PostgreSQL 16: она не обращается к рабочей схеме, добавляет nullable-колонку, переносит две строки и показывает остаток старой формы. Имена клиентов здесь используются только для учебного соответствия; в реальной миграции совпадение должно опираться на устойчивый уникальный ключ.
CREATE TEMP TABLE customers (\n id bigint PRIMARY KEY,\n name text UNIQUE NOT NULL\n);\nCREATE TEMP TABLE orders (\n id bigint PRIMARY KEY,\n customer_name text NOT NULL\n);\n\nINSERT INTO customers VALUES (101, 'Ada'), (102, 'Grace');\nINSERT INTO orders VALUES (1, 'Ada'), (2, 'Grace');\n\n-- Expand: старый writer по-прежнему может писать customer_name.\nALTER TABLE orders ADD COLUMN customer_id bigint;\n\n-- Migrate: ограниченный набор строк, повторный запуск пропускает заполненные.\nUPDATE orders AS o\nSET customer_id = c.id\nFROM customers AS c\nWHERE o.customer_name = c.name\n AND o.customer_id IS NULL;\n\nSELECT id, customer_id\nFROM orders\nORDER BY id;\nSELECT count(*) FILTER (WHERE customer_id IS NULL) AS remaining\nFROM orders;\nОжидаемый результат для этого набора — две строки с идентификаторами 101 и 102 и remaining = 0. Это не доказательство готовности production: в рабочем запуске нужны размер партии, cursor, лимит скорости, наблюдение за блокировками и проверка конкурирующих writers. Если имя не сопоставляется однозначно, строку нельзя заполнять случайным совпадением.
Backfill отвечает на вопрос «как преобразовать старые строки». Он не должен менять договор совместимости. Запускайте его как отдельный процесс с областью, cursor, размером партии, лимитом скорости, idempotency-правилом и измеримым стоп-сигналом. Партия из тысячи строк не является безопасной сама по себе. Она может запускаться без конца, конкурировать с пользовательскими запросами или повторно менять одну строку после сбоя.
\nИдемпотентный шаг проверяет текущее состояние перед записью. Если строка уже имеет корректный customer_id, повторный запуск её пропускает. Если соответствие неоднозначно, job должна остановиться или отправить строку на ручной разбор. Она не должна выбирать первый результат молча. В миграции данных неопределённость — это сигнал остановки, а не повод увеличить batch.
Учебный псевдокод ниже ограничен памятью процесса. Он не читает настоящую БД, не измеряет locks, не запускает транзакции и не сообщает о готовности production.
\nfor (const batch of batches(records, 100)) {\n const updates = [];\n\n for (const record of batch) {\n if (record.customer_id != null) continue;\n\n const match = lookupCustomer(record.customer_name);\n if (match.kind !== 'unique') {\n throw new Error(`stop: ${record.id} needs review`);\n }\n\n updates.push({ id: record.id, customer_id: match.id });\n }\n\n applyUpdates(updates);\n saveCursor(batch.at(-1).id);\n}\nВ примере ошибка останавливает весь учебный проход. В production решение может быть другим: отдельная quarantine-очередь, транзакция на партию или ручное подтверждение. Важно другое: неоднозначная строка не получает случайное значение, cursor сохраняется, а повторный запуск видит уже обработанные записи. Учебный пример не является готовой библиотекой миграции.
\nПереключение чтения не равно завершению backfill. Оно означает, что new reader умеет обработать остаток старой формы и команда согласовала, что делать с null, конфликтом и повторной записью. После переключения наблюдайте ошибки, долю fallback, расхождения двух форм и время обработки. Не удаляйте старое поле сразу после первого зелёного графика.
\nПока old writer или долгоживущий worker ещё может работать, new writer должен сохранять совместимость. Это может быть dual write, событие для отдельного consumer или другой явно описанный механизм. Dual write тоже создаёт риск: записи могут завершиться только в одной форме, а порядок событий может расходиться. Поэтому нужна проверка расхождений и правило исправления. Сам термин dual write ничего не гарантирует.
\nStripe описывает похожий четырёхэтапный путь для своей онлайн-миграции: dual write, перевод чтений, перевод записей и удаление старых данных. Это инженерный разбор инфраструктуры Stripe, а не универсальная гарантия. В другой системе нужно отдельно проверить объём, lock behavior, задержки, ретраи и все пути записи.
\nContract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это полезный финал, но не обычный rollback. Возврат к старому бинарнику восстановит код, а не удалённые значения. Если старое представление нужно для восстановления, его сохраняют до contract: backup проверяют восстановлением, копию снабжают сроком хранения, а divergence связывают с понятным действием.
\nНе называйте contract готовым по одному признаку. Green build не знает о ручном SQL-клиенте. Нулевой fallback за минуту не доказывает, что вчерашний worker завершился. Полная проверка должна охватывать writers, readers, jobs, очереди, отчёты и восстановление. Если список consumers неполон, безопасное действие — продлить совместимый период.
\nExpand–migrate–contract не выбирает isolation level, batch size, lock timeout, retention, backup policy или график запуска. Он не заменяет требования к PII, disaster recovery, capacity planning и проверку прав. PostgreSQL, Stripe и учебный JavaScript-пример описывают разные границы. Их нельзя объединять в обещание zero downtime.
\nДля конкретной миграции критерий готовности проверяем: old и new версии имеют записанный контракт; expand не отвергает old writer; backfill можно остановить и повторить; неоднозначные записи не получают default; список consumers подтверждён; fallback и расхождения наблюдаемы; backup восстановлен на тестовой копии; contract имеет отдельное решение и срок хранения recovery-артефактов.
\nЕсли хотя бы один пункт не подтверждён, миграция не готова к следующему необратимому шагу. Это не провал плана. Это точная граница знания: команда видит, какой факт нужно получить до изменения данных.
\nСборка проходит, тесты зелёные, но следующий небольшой import внезапно требует правок в трёх пакетах. Общая utility знает про доменный статус счёта. Feature импортирует её внутренний cache по файловому пути. После этого изменение enum затрагивает форматтер, а переименование cache ломает consumer. Цена ошибки — скрытая связанность, более длинные ревью и миграция, которую нельзя выполнить одним владельцем.
\nТезис простой: граница пакета — это не каталог и не слово shared. Это проверяемый договор о том, какие имена доступны, кто владеет смыслом данных и какие пути запрещены. Если договор не записан, рабочий import постепенно становится частью API. Если договор записан, нарушение можно увидеть до релиза.
Рассмотрим учебный пример. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.
Первый import переносит доменное решение в техническую utility. Formatter теперь должен понимать, какие состояния бывают у счёта и какой текст им соответствует. Второй import превращает внутреннее устройство utility в обещание consumer-у. Эти нарушения нельзя лечить одной настройкой lint: у них разные владельцы и разные исправления.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Utility импортирует InvoiceStatus | Доменный смысл оказался в общем слое | Найти владельца enum и проследить, кто выбирает label | Оставить в formatter только primitive inputs; mapping вернуть в billing |
Consumer импортирует /internal/* | Public surface не описывает нужную операцию | Сверить specifier с package root и списком exports | Добавить осмысленный root export или убрать зависимость от cache |
| Никто не может назвать public names | Контракт существует только в соглашениях команды | Попросить owner указать root, имена и запретные маршруты | Создать короткую запись API с владельцем и сроком пересмотра |
| Предлагают сразу отключить правило | Инструмент подменяет архитектурное решение | Отделить допустимый adapter от случайного deep import | Сначала принять решение о границе, затем настроить static guard |
Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он начинает зависеть не от формы входа, а от причины, по которой вход существует.
У deep import другой механизм. Consumer перестаёт зависеть от обещанного поведения и начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета больше не может свободно поменять cache, разнести код по файлам или изменить стратегию invalidation. Даже если Node или bundler сегодня разрешает путь, это ещё не делает путь публичным.
\n// Учебный пример: домен выбирает смысл, utility форматирует данные. type InvoiceView = { amountMinor: number; currencyCode: string; statusLabel: string }; export function renderInvoice(view: InvoiceView, locale: string) { const amount = new Intl.NumberFormat(locale, { style: 'currency', currency: view.currencyCode }).format(view.amountMinor / 100); return `${amount} — ${view.statusLabel}`; } const view = { amountMinor: invoice.amountMinor, currencyCode: invoice.currencyCode, statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'К оплате' }; renderInvoice(view, 'ru-RU');\nВ примере statusLabel — осознанная граница. Billing меняет текст и правила статуса. Formatter получает данные, достаточные для форматирования, но не получает право расширять модель счёта. Это учебная иллюстрация, а не утверждение о конкретном production-коде.
Начните не с glob-паттерна, а со списка обещаний. Для условного пакета запись может выглядеть так:
\n// Учебная запись контракта, не готовая конфигурация проекта. const boundary = { root: '@example/platform-formatting', publicNames: ['formatMoney', 'formatDate'], forbidden: ['@example/platform-formatting/internal/*'], owner: 'formatting-team', reviewBy: '2026-09-01' };\nПоле root отвечает на вопрос, откуда импортировать. publicNames отделяет API от случайно экспортированного файла. forbidden показывает, что internal-пути не входят в обещание. Owner принимает изменения surface. Дата пересмотра нужна для временного adapter-а: без неё временная лазейка становится постоянной.
После этого можно выбрать техническую проверку. В Node поле exports задаёт разрешённые entry points и subpaths для package resolution. TypeScript при подходящем moduleResolution учитывает этот контракт, но настройки должны соответствовать runtime или bundler. ESLint может ловить запрещённые static imports. Ни один из этих механизмов не отвечает за смысл InvoiceStatus и не доказывает, что dynamic loader соблюдает тот же договор.
Если cache нужен только formatter-у, consumer должен вызывать public функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите её в отдельный reviewed export. Зафиксируйте входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.
\nAdapter допустим, когда он имеет владельца и срок жизни. Например, старый consumer может временно вызывать formatMoneyAdapter, пока команда мигрирует на новый root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а проверка удаления — иметь конкретный сигнал.
Поле exports не делает любую архитектуру правильной. Внутренние и внешние пакеты отличаются по semver-обязательствам. Legacy consumers могут требовать переходный слой. TypeScript может разрешить типы в одной конфигурации, а runtime или bundler — разрешить их иначе. Поэтому проверяйте фактическую toolchain, а не только редактор и компилятор.
Static rule не видит все способы загрузки кода. Dynamic import(), generated files и framework entry points требуют отдельного решения. Нельзя объявлять отсутствие lint-ошибки доказательством отсутствия зависимости. Нельзя и запрещать весь pattern без исключений: так adapter-ы получат suppressions, а реальные нарушения станут менее заметны.
Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у. Если consumer уже использует internal path, не публикуйте весь каталог ради совместимости. Найдите операцию, которую consumer действительно требует, и оформите только её. Если такой операции нет, удалите зависимость и оставьте cache деталью владельца.
\nРаботу можно считать готовой, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Дополнительно должны проходить type check и static guard в поддерживаемой конфигурации, а consumer должен использовать root API. Для временного adapter-а указаны owner, срок удаления и проверка его удаления.
\nКритерий не требует доказать, что весь монорепозиторий свободен от domain leak. Он требует доказать одну согласованную границу на конкретном import-е. Это ограничение делает результат честным: учебный код показывает механизм, а реальный diff и выбранные проверки показывают состояние системы.
\nexports, public entry points и subpaths.moduleResolution.Сборка проходит, тесты зелёные, но следующий небольшой import внезапно требует правок в трёх пакетах. Общая utility знает про доменный статус счёта. Feature импортирует её внутренний cache по файловому пути. После этого изменение enum затрагивает форматтер, а переименование cache ломает consumer. Цена ошибки — скрытая связанность, более длинные ревью и миграция, которую нельзя выполнить одним владельцем.
\nГраница пакета — не каталог и не слово shared. Это договор о доступных именах, смысле данных и запрещённых путях. Его можно проверить в исходниках, настройках разрешения модулей и статическом анализаторе. Но сначала нужно решить, кто владеет смыслом. Инструмент способен поймать deep import, но не способен определить, кому принадлежит правило «просроченный счёт».
Рассмотрим синтетический кейс, чтобы не выдавать учебную схему за отчёт о production-системе. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.
Первый import переносит доменное решение в техническую utility. Formatter теперь должен понимать, какие состояния бывают у счёта и какой текст им соответствует. Второй import превращает внутреннее устройство utility в обещание consumer-у. Эти нарушения связаны общей потерей договора, но исправляются по-разному: mapping статуса возвращается владельцу billing, а cache либо остаётся внутренним, либо получает отдельный осмысленный API.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Utility импортирует InvoiceStatus | Доменный смысл оказался в общем слое | Найти владельца enum и того, кто выбирает label | Оставить в formatter primitive inputs; mapping вернуть в billing |
Consumer импортирует /internal/* | Файловое устройство приняли за public API | Сверить specifier с root export и списком exports | Добавить reviewed root export или убрать зависимость от cache |
| Никто не может назвать public names | Контракт существует только в соглашениях команды | Попросить owner указать root, имена и запретные маршруты | Создать короткую API-запись с владельцем и сроком пересмотра |
| Предлагают сразу отключить lint | Инструмент подменяет архитектурное решение | Отделить допустимый adapter от случайного deep import | Сначала принять решение о границе, затем настроить guard |
Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он зависит уже не от формы входа, а от причины, по которой вход существует.
Это правило действует и для type-only import. Такой импорт может исчезнуть из исполняемого JavaScript, но остаётся в исходном коде и декларациях. Formatter всё равно знает словарь billing. Полезная проверка здесь не «исчез ли тип из bundle», а «может ли владелец billing изменить набор статусов без изменения контракта общей utility».
\nУ deep import другой механизм. Consumer начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета уже не может свободно переименовать cache, изменить invalidation или разнести реализацию по файлам. То, что bundler сегодня разрешает путь, ещё не делает его публичным.
\nНиже функция запускается на Node.js без библиотек. Вход содержит сумму в минимальных единицах и код валюты. Смысл статуса выбирается до вызова formatter-а, поэтому utility не импортирует InvoiceStatus. Скопируйте одну команду в терминал: она печатает локализованную сумму и подпись статуса.
node -e "const invoice={status:'overdue',amountMinor:12345,currencyCode:'RUB'}; const formatInvoice=(view,locale='ru-RU')=>{const amount=new Intl.NumberFormat(locale,{style:'currency',currency:view.currencyCode}).format(view.amountMinor/100); return amount+' — '+view.statusLabel}; const view={amountMinor:invoice.amountMinor,currencyCode:invoice.currencyCode,statusLabel:invoice.status==='overdue'?'Просрочен':'К оплате'}; console.log(formatInvoice(view))"\nВызов Intl.NumberFormat отвечает только за представление числа и валюты. Поле statusLabel подготовил код billing. Если появится состояние disputed, меняется mapping домена; сигнатура formatter-а не обязана узнавать об этом состоянии. Это демонстрация границы, а не утверждение о конкретном репозитории.
Начните со списка обещаний, а не с glob-паттерна. Для условного пакета запись может выглядеть так:
\nconst boundary={root:'@example/platform-formatting',publicNames:['formatMoney','formatDate'],forbiddenConsumerRoutes:['@example/platform-formatting/internal/*'],forbiddenUtilityTargets:['@example/billing-domain/*'],owner:'formatting-team',reviewBy:'2026-09-30'};\nroot отвечает на вопрос, откуда импортировать. publicNames отделяет API от случайно доступного файла. forbiddenConsumerRoutes и forbiddenUtilityTargets показывают два разных направления запрета. Owner принимает изменения surface, а reviewBy не даёт временному adapter-у стать постоянной лазейкой.
В настоящем проекте эту запись нужно связать с конкретным package entry point и тестом. Не называйте public-ом весь каталог только потому, что один consumer уже нашёл в нём helper. Публикуйте операцию с понятными входами, выходом, lifetime и правилами совместимости.
\nПоле exports в package.json задаёт доступные entry points и subpaths при обычном разрешении package specifier. Если ./internal/cache не перечислен, импорт через имя пакета должен быть отклонён Node как неэкспортированный subpath. Это ограничивает package surface, но не отвечает за доменную архитектуру. Локальный абсолютный путь, особый loader или generated code требуют отдельной проверки.
{"name":"@example/platform-formatting","exports":{".":"./dist/index.js","./package.json":"./package.json"}}\nTypeScript в режимах node16, nodenext и поддерживаемом проектом bundler сопоставляет разрешение модулей с package maps. Это уменьшает расхождение между type-check и запуском, если compiler и runtime настроены согласованно. Сам компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility.
ESLint с правилом no-restricted-imports подходит для названных статических маршрутов. Можно запретить consumer-ам @example/platform-formatting/internal/*, а utility — импорты @example/billing-domain/*. Правило должно предлагать легальную альтернативу. Оно не строит полный граф dynamic import(), generated files и runtime plugin loading, поэтому область его обещания нужно написать рядом с конфигурацией.
Если cache нужен только formatter-у, consumer должен вызывать публичную функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите стабильную операцию в reviewed export и опишите входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.
\nAdapter допустим, когда у него есть владелец и срок жизни. Старый consumer может временно вызывать formatMoneyAdapter, пока команда переходит на root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а условие удаления — измеримым: например, поиск запрещённого specifier больше не находит потребителей.
Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у и передайте formatter-у primitive или display data. Если consumer использует internal path, найдите требуемую операцию. При отсутствии стабильной семантики удалите зависимость и оставьте cache деталью владельца.
\nЭта схема не делает пакеты независимыми автоматически. Внутренние и внешние пакеты имеют разные semver-обязательства. Legacy consumers могут требовать переходный слой. Generated clients, plugin systems и framework entry points могут законно пересекать обычные слои. Для них нужны явный маршрут, owner и отдельная проверка.
\nexports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript может разрешить типы в одной конфигурации, а runtime — разрешить их иначе. Static rule не доказывает отсутствие dynamic загрузки. Поэтому результат нужно формулировать узко: «названный static import из заданного scope запрещён», а не «в репозитории больше нет domain leak».
Пример использует целые сотые валютной единицы и простую подпись. Он не решает вопросы округления, налогов, plural rules, доступности, юридических формулировок и финансовой точности конкретного продукта. Для реального billing-кода эти правила должны принадлежать доменному контракту и иметь собственные тесты.
\nГраницу можно принять, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Consumer использует root API. Type check и static guard проходят в поддерживаемой toolchain. Для временного adapter-а записаны owner, срок удаления и сигнал, по которому его можно удалить.
\nКритерий не требует доказать, что весь монорепозиторий свободен от доменных утечек. Он требует доказать одну согласованную границу на конкретном import-е: показать diff, проверку разрешения модуля, отрицательный тест и owner решения. Это ограничение делает вывод честным и оставляет команде воспроизводимый следующий шаг.
\nexports, public entry points и subpaths.