From 112bfe3601fafdd0cde7581268e886f66ce7b77e Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 16:26:34 +0300 Subject: [PATCH] editorial: revise articles 131-136 to 10/10 --- editorial/agent-rewrites/131.json | 2 +- editorial/agent-rewrites/132.json | 4 ++-- editorial/agent-rewrites/133.json | 4 ++-- editorial/agent-rewrites/134.json | 2 +- editorial/agent-rewrites/135.json | 2 +- editorial/agent-rewrites/136.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) 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-омСмешаны копирование файлов и наследование измененийПроверить историю и реальный канал обновленийЗафиксировать самостоятельное владение или выбрать другой механизм
\"Развилка
Схема выбора: вход сначала сравнивается с контрактом. Иллюстрация объясняет логику маршрута и не показывает метрики adoption, состояние CI или production-результат.

Закрытый контракт параметров

У формы должен быть конечный список допустимых значений. В учебном примере базовый путь создаёт только внутренний 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 и extension point

Base path фиксирует повторяемую часть: компонент известного типа, назначенного владельца, одобренный runtime и заранее определённый класс данных. Он может создать skeleton, metadata и список обязательных проверок. Он не должен принимать вопрос «какой runtime выбрать позже». Отложенное решение не становится безопасным оттого, что его записали в параметр.

Extension point нужен для узкого отличия, которое не ломает базовые инварианты. У расширения должны быть identifier, owner, версия, граница изменений и условие удаления. Observability adapter подходит как учебный пример, если он добавляет один известный слой наблюдаемости. Новый класс данных, внешний доступ или другая модель retention уже меняют тип решения. Их нельзя спрятать за словом extension.

Проверяйте расширение до генерации. Сначала найдите canonical record для типа заявки. Затем сравните с ним все входы. Если запись не найдена, результатом должен быть decline. Если совпал base contract и добавлено одно разрешённое отличие, создайте review record. Только после review запускайте action, который публикует файлы.

Почему repository template не равен fork

GitHub repository template создаёт новый репозиторий с той же структурой и файлами. GitHub отдельно указывает, что ветви такого репозитория имеют несвязанные истории. Это полезный старт, но не канал автоматической синхронизации с шаблоном. Команда получает начальный набор файлов и дальше принимает собственный жизненный цикл, если другой договор не определён отдельно.

Fork решает другой вопрос. Он сохраняет историю родительского репозитория и подходит для совместной работы с исходным проектом. Нельзя обещать потребителю updates от template только потому, что интерфейс запуска называется похожим образом. Перед внедрением зафиксируйте, что именно распространяется: копия файлов, pull request-ы, пакет, action или самостоятельная версия. У механизма должен быть владелец доставки изменений.

Backstage Software Templates тоже не заменяют этот договор. Scaffolder принимает параметры, выполняет шаги, подставляет переменные и может публиковать результат в GitHub или GitLab. Эти возможности описывают способ создания компонента. Они не доказывают, что любой action разрешён, что вход соответствует policy или что созданные репозитории будут синхронизироваться. Контракт команды остаётся отдельным слоем.

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

  1. Опишите класс задачи. Запишите component type, owner, runtime, data class, access boundary и срок жизни. Если ответ неизвестен, не маскируйте пробел новым флагом.
  2. Зафиксируйте base contract. Назовите обязательные входы, допустимые значения, результат и список non-goals. Версия контракта меняется вместе с этим договором.
  3. Закройте схему. Отклоняйте неизвестные keys и неполные комбинации. Не принимайте raw policy, credential, произвольный path или неподтверждённую метрику как вход.
  4. Разведите результаты. Полное совпадение даёт golden path. Одно разрешённое отличие даёт review extension. Несовместимое или неизвестное значение даёт decline.
  5. Проверьте отрицательный путь. Подайте неизвестный runtime, внешний data class, пустой owner и неподдерживаемое extension. Каждый случай должен остановиться до создания репозитория.
  6. Проведите review расширения. Проверьте owner, boundary, права, version, rollback и условие удаления. Если хотя бы одного поля нет, верните запрос на design path.
  7. Запустите публикацию только после проверки. В рабочей среде отдельно подтвердите credentials, результат pipeline, healthcheck, наблюдаемость и откат. Учебный пример выше этого не делает.
  8. Пересмотрите решение. Если расширение повторяется и сохраняет инварианты, включите его в новую версию контракта. Одноразовый случай не превращайте в обязательную опцию.

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

Шаблон не выбирает 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, а повторный запуск имеет явно определённое поведение. Это проверяемый контракт, а не обещание универсальной платформы.

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

"} +{"index":131,"slug":"editorial-2024-05-mechanism-platform-templates","title":"Платформенный шаблон как контракт: базовый путь, расширение и отказ","excerpt":"Как превратить шаблон для команд в ограниченный контракт: отделить копию репозитория от наследования, закрыть входные параметры и остановить запрос, который требует отдельного архитектурного решения.","contentHtml":"

Команда просит создать внутренний 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 extensionProposal; публикация только после разрешения
Появился внешний доступ или другой класс данныхСравнить инварианты и права; найти отдельный policy recordDeclineТолько причина отказа и следующий вопрос
Поле или значение неизвестноПроверить схему и список разрешённых actionDeclineРепозиторий не создаётся
Контур решения для платформенного шаблона: запись входа, сравнение с версионированным контрактом, golden path, именованное расширение или отказ, затем evidence и review
Сначала фиксируются факты и границы заявки, затем выбирается маршрут. Иллюстрация показывает порядок принятия решения, но не утверждает метрики внедрения или готовность конкретного репозитория.

Шаблон, template-repository и fork — разные вещи

Термин «шаблон» описывает и механизм, и начальный набор файлов, поэтому здесь легко ошибиться. 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 и extension point

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.

  1. Сохраните контракт. Зафиксируйте версию, обязательные поля, допустимые enum и инварианты, которые нельзя менять extension-ом.
  2. Запустите базовый dry-run. Передайте короткое имя, существующую группу, internal и none. Проверьте, что skeleton содержит ожидаемые metadata и список проверок.
  3. Запустите разрешённое расширение. Замените только extension на observability-adapter. Сравните diff и убедитесь, что не появились внешний доступ, другой runtime или новая политика хранения.
  4. Проверьте отказ. Подайте неизвестное значение, внешний класс данных и лишний ключ. Каждый случай должен остановиться до publish:github и вернуть понятную причину.
  5. Проверьте повтор. Повторите базовый запуск с теми же параметрами. Убедитесь, что генератор не создаёт второй обязательный action, дублирующую регистрацию или неожиданный ресурс.
  6. Проверьте реальную публикацию отдельно. После review подтвердите credentials, branch protection, CI, healthcheck, наблюдаемость и откат в окружении команды. Результат dry-run нельзя выдавать за результат production-развёртывания.

Для различия 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. Для каждого сохраните вход, вердикт, созданные артефакты и причину отказа. Такой набор даёт команде воспроизводимый контракт и показывает, где шаблон заканчивается. Всё, что требует нового класса данных, доступа или жизненного цикла, должно выйти за эту границу явно.

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

"} diff --git a/editorial/agent-rewrites/132.json b/editorial/agent-rewrites/132.json index 33bc7e7..333cd1c 100644 --- a/editorial/agent-rewrites/132.json +++ b/editorial/agent-rewrites/132.json @@ -2,6 +2,6 @@ "index": 132, "slug": "editorial-2024-05-practice-platform-templates", "title": "Платформенный шаблон без ловушки универсальности: контракт, расширение и отказ", - "excerpt": "Шаблон экономит время только там, где повторяется один и тот же класс решений. Разбираем границы golden path, named extension, отрицательный путь и проверяемый критерий готовности.", - "contentHtml": "

Новая команда открывает заявку на сервис и получает знакомый ответ: возьмите соседний репозиторий, замените имя, а остальное поправьте по месту. Через неделю два сервиса уже расходятся по CI, healthcheck и владельцам конфигурации. Ошибка проявляется не при создании, а на первом изменении общего правила. Review приходится сравнивать с несколькими копиями, исправление нужно переносить вручную, а rollback зависит от того, какая копия стала «правильной». Цена ошибки — не лишний файл. Команда теряет время на восстановление контракта и получает несколько путей, за которые никто не отвечает.

\n

Тезис простой: платформенный шаблон должен фиксировать только повторяемый класс решений. У класса есть owner, версия, обязательные входы, инварианты и ограниченный выход. Всё, что меняет этот класс, выносят в именованное расширение. Всё неизвестное отправляют на отдельное архитектурное решение. Такой шаблон остаётся коротким golden path и не превращается в форму со скрытой политикой.

\n

Что именно делает шаблон

\n

Шаблон — это не обещание готового сервиса. Он может положить согласованную структуру каталогов, подставить имя, подготовить описание компонента и передать результат в выбранное место. Backstage называет такой механизм Software Templates: он загружает skeleton, подставляет переменные и может опубликовать результат в GitHub или GitLab. Это полезная граница автоматизации. Она отвечает на вопрос «как получить одинаковый старт», но не отвечает на вопросы о доступе к данным, security review, capacity или разрешении на выпуск.

\n

Поэтому сначала описывают договор, а потом файлы. Для внутреннего HTTP-сервиса договор может содержать owner, approved runtime, класс данных, обязательный health endpoint и способ регистрации в каталоге. Он также должен содержать отрицательную часть: шаблон не выдаёт production approval, не создаёт секреты, не выбирает retention и не меняет права. Без этой части любой новый параметр легко станет незаметным исключением.

\n

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

\n
Диагностика шаблона до его расширения
СимптомПричинаПроверкаДействие
В форме появляется «ещё одна галочка» для особого runtime.В один шаблон поместили два разных класса компонентов.Сравнить owner, runtime, data class и обязательные проверки для обоих вариантов.Оставить один класс. Для второго открыть отдельный design path.
После создания каждый репозиторий правят вручную.Выход шаблона считают договором, хотя он содержит только стартовые файлы.Показать, какие инварианты сохраняются после правки и кто владеет каждым правилом.Вынести повторяемую правку в версионированный шаблон или named extension.
Один generated repository нужно синхронизировать с базой.Создание из template перепутали с fork или наследованием.Проверить историю и способ доставки изменений из исходного репозитория.Не обещать автоматическую синхронизацию. Выбрать обновление вручную или другой механизм.
Неизвестный владелец всё равно проходит форму.Проверку ownership оставили на потом.Сделать owner обязательным входом и остановить запуск при пустом значении.Вернуть заявку на уточнение ответственности.
Шаблон должен выбрать retention или access policy.Архитектурное решение спрятали в параметр интерфейса.Спросить, кто утвердил policy и действует ли она для всего класса.Убрать параметр из base path и провести отдельную проверку.
\n

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

\n

Пример короткого контракта

\n

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

\n
const 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. Это важнее положительной ветки: неизвестная политика не должна незаметно превращаться в настройку по умолчанию.

\n

Имена полей также задают границы ответственности. component-owner отвечает за доменный компонент. owner контракта отвечает за сам шаблон. Эти роли могут принадлежать одной группе, но смешивать их в одно не стоит. Иначе пользователь сможет создать компонент без владельца, потому что «платформа же владеет формой».

\n

Узкий golden path

\n

Узкий путь не означает бедный путь. Он может включать структуру документации, labels, базовый health endpoint, регистрацию компонента и обязательные проверки. Ограничение относится к смыслу выбора. Каждое поле должно иметь одинаковое значение для заявленного класса. Поле «выбрать любую базу» не является частью golden path, если разные базы меняют отказоустойчивость, хранение данных и эксплуатацию. Поле «выбрать approved runtime из двух поддерживаемых» может быть допустимым, если правила для обоих вариантов уже определены и owner готов их поддерживать.

\n
\"Схема
Учебная схема отделяет стабильный base contract от policy-вопроса. Она не показывает живую платформу, статистику использования или результат выпуска.
\n

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

\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

Порядок принятия решения

\n
  1. Назовите класс. Одним предложением опишите компонент, owner, runtime и класс данных. Если описание требует слова «обычно» или «иногда», граница ещё не определена.
  2. Запишите инварианты. Перечислите то, что должно остаться истинным после применения шаблона: обязательные метаданные, тип компонента, проверки и границы доступа.
  3. Отделите выход от разрешения. Укажите, что шаблон создаёт skeleton или draft, но не выдаёт approval, секреты и право на изменение среды.
  4. Проверьте расширение. Для единственного известного отклонения назовите extension, owner, условия входа и rollback. Если отклонений несколько и они меняют класс, не прячьте их в одной форме.
  5. Проверьте отрицательную ветку. Передайте неизвестного owner, новую retention policy и неподдерживаемый runtime. Каждый вход должен получить ясный stop, а не молча выбранный default.
  6. Выберите механизм доставки. Если результат должен жить независимо, template repository подходит для старта. Если изменения должны приходить из общего источника, используйте механизм с проверяемой связью, а не обещание синхронизации.
  7. Назначьте проверку результата. Укажите, кто сверяет входы, созданные файлы, metadata и права. Положительный ответ открывает следующий review, но не заменяет его.
  8. Зафиксируйте версию. Запишите версию контракта и действие при обновлении. Не меняйте смысл старого входа под тем же номером: добавьте новую версию или отдельный путь.
\n

Когда отказ — правильный результат

\n

Заявка на разовую миграцию регулируемых данных обычно не готова к service template. У неё могут быть неизвестны owner и lifecycle, а access и retention требуют отдельного решения. Если вставить такую задачу в форму, пользователь заполнит поля, но архитектура останется неразрешённой. Шаблон создаст уверенный вид, а вопросы проявятся после получения файлов.

\n

Остановка не означает запрет на работу. Она возвращает заявку на правильную границу: design record, security review или обсуждение владельца данных. После нескольких одинаковых решений может появиться новый класс. Тогда его можно оформить отдельным контрактом с собственным owner и проверками. Не делайте этот вывод по одной удачной заявке.

\n

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

\n

Шаблон не заменяет threat modeling, capacity planning, CI, тесты, миграционный план и проверку прав. Он не доказывает, что созданный сервис можно выпускать. Он также не обязан поддерживать каждый исторический репозиторий. Base contract задаёт повторяемый старт, а не универсальную архитектуру компании.

\n

Rollback должен быть определён на уровне изменения. Для draft удаляют draft. Для extension возвращаются к versioned base contract и удаляют подключение по его инструкции. Для уже созданного репозитория отдельно проверяют, что возвращается: файлы, зависимость, конфигурация или данные. Возврат шаблона не откатывает автоматически изменения, которые команда внесла после создания.

\n

Материал готов к применению как учебная схема, когда второй инженер без устных пояснений может показать класс, owner, версию, обязательные входы, инварианты, выход, refusal criterion и rollback. Для каждой ссылки есть источник. Для каждого неизвестного входа есть stop. В примере выше все имена и значения вымышлены; они не описывают измерение, внедрение или результат в production.

\n

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

\n" + "excerpt": "Шаблон экономит время только там, где повторяется один и тот же класс решений. Разбираем границы golden path, именованного расширения, отрицательного пути и проверяемой готовности.", + "contentHtml": "

Новая команда просит создать внутренний сервис и получает знакомый совет: скопируйте соседний репозиторий, замените имя, остальное поправьте после запуска. Сначала это выглядит дешевле платформенного шаблона. Через несколько недель копии расходятся по CI, healthcheck и владельцам конфигурации. Когда меняется общее правило, его приходится искать в каждом репозитории. Review сравнивает несколько версий «правильного» старта, а rollback зависит от того, какая копия успела стать образцом.

\n

Проблема не в количестве файлов. Команда потеряла контракт: какие входы обязательны, что именно создаётся, кто владеет правилом и когда генератор должен остановиться. Платформенный шаблон полезен, пока фиксирует повторяемый класс решений. Для известного варианта нужен узкий golden path, для одного контролируемого отклонения — именованное расширение, а неизвестная политика должна уйти в отдельное решение.

\n

Симптом: форма обещает больше, чем система знает

\n

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

\n

Проверка должна быть предметной. Для каждого поля назовите его owner, одинаковый смысл для заявленного класса и обратимое действие при ошибке. Если значение зависит от конкретной команды или ещё не утверждённой политики, поле не принадлежит base template. Если после создания результат нужно регулярно подтягивать из общего источника, это уже задача управления зависимостью или upstream-процесса, а не простого копирования.

\n

Сначала контракт, затем генерация

\n

У рабочего шаблона есть небольшой закрытый договор. Он описывает идентификатор и версию, целевой тип компонента, обязательные входы, создаваемые артефакты и явные отказы. Например, для внутреннего HTTP-сервиса обязательными входами могут быть имя сервиса, владелец компонента, одобренный класс runtime и класс данных. Выходом будет skeleton репозитория, черновик метаданных каталога и checklist review.

\n

Отрицательная часть договора не менее важна. Шаблон не выдаёт production approval, не создаёт секреты по своему усмотрению, не выбирает новую retention policy и не меняет права доступа. Положительный результат проверки означает только «вход подходит для этого стартового пути». Он не означает, что сервис безопасен, выдержит нагрузку или готов к выпуску.

\n
Граница base contract для внутреннего HTTP-сервиса
ЧастьФиксируемНе прячем в шаблонПроверка
ИдентичностьИмя сервиса, владелец компонента, версия шаблонаБудущее название команды или продуктаВладелец найден в разрешённом справочнике
КлассInternal HTTP service и approved runtimeНовый runtime ради одной заявкиКласс и runtime входят в поддерживаемый список
ДанныеУже утверждённый internal data classНовая retention или access policyСсылка на действующее правило и его owner
ВыходSkeleton, metadata draft, checklist reviewProduction approval, итог CI и разрешение на выпускАртефакты проверены отдельным review
ОтклонениеИменованное расширение с owner и rollbackБезымянный patch в каждом generated repoОписаны условия включения и удаления
\n

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

\n

Что реально делает scaffolder

\n

Backstage называет этот механизм Software Templates. Официальная документация описывает загрузку skeleton-кода, подстановку переменных и публикацию результата в такие места, как GitHub или GitLab. Шаблон состоит из шагов, а перед запуском пользователь может проверить введённые параметры на review-странице. Это хороший механизм повторяемого старта, но он не определяет политику конкретной компании сам по себе.

\n

Публикация требует настроенных интеграций и разрешений приложения. Поэтому из того, что Backstage умеет вызвать publish action, нельзя делать вывод о наличии прав у каждой команды или о прохождении security review. В вашем экземпляре нужно отдельно проверить, какие actions включены, куда им разрешено писать и какой процесс принимает созданный репозиторий. Версия Backstage также имеет значение: названия полей и доступные actions сверяйте с документацией установленного релиза.

\n

В этой статье слово «расширение» означает локальное соглашение команды, а не встроенный объект Backstage. Оно должно иметь имя, owner, условия входа, список изменяемых артефактов, способ удаления и дату пересмотра. Такое описание оставляет отличие видимым. Без него расширение быстро превращается в ещё одну копию base template.

\n

Воспроизводимая отрицательная проверка

\n

Ниже маленькая in-memory-проверка. Она не создаёт репозиторий, не обращается к Backstage, не меняет CI и не проверяет реальные права. Её можно вставить в файл и выполнить Node.js без зависимостей. Ожидаемый вывод — сначала true, затем false: новая политика не должна пройти через тот же путь, что и согласованный internal service.

\n
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 существует у вас.

\n

Узкий golden path

\n

Узкий путь не означает бедный путь. В него можно включить структуру документации, обязательные labels, регистрацию компонента, базовый health endpoint и согласованный delivery-маршрут. Ограничение относится к смыслу выбора: каждое поле должно иметь одно и то же значение для всего класса. Поле «выберите любую базу» нарушает это правило, если базы меняют отказоустойчивость, хранение данных и эксплуатационные обязанности.

\n
\"Схема
Стабильные входы ведут в версионированный договор; неизвестный owner или новая policy не становятся ещё одним полем формы. Схема учебная и не описывает конкретную production-платформу.
\n

У каждого правила должен быть ответ на вопрос «кто изменит его, если оно устареет?». Для шаблона это может быть команда платформы, для data policy — владелец данных, для каталожных метаданных — отдельный owner. Число успешных запусков не доказывает правильность договора. Результат нужно сверить с созданными файлами, metadata, правами и обязательными проверками.

\n

Именованное расширение вместо локальной копии

\n

Отклонение допустимо как расширение, если базовый класс не меняется. Например, сервис остаётся внутренним HTTP-сервисом, сохраняет approved runtime и data class, но подключает заранее определённый observability adapter. У extension должны быть имя, owner, входное условие, граница изменений и rollback. Его подключение должно быть видно в результате генерации и в review, а не существовать только в памяти автора.

\n

Если расширение меняет класс данных, доступ, retention или право на выпуск, это уже не «одна дополнительная настройка». Такой вариант нужно вынести в собственный контракт с отдельными проверками. Если одинаковый локальный patch повторился несколько раз, соберите факты: совпадает ли класс задач, одинаковы ли условия и можно ли удалить изменение без ручного поиска по всем копиям. Только после этого решайте, стал ли patch расширением или отдельным шаблоном.

\n

Template repository — не канал синхронизации

\n

У GitHub есть важное ограничение, которое легко потерять в рекламном описании шаблона. Документация GitHub указывает, что ветви репозитория, созданного из template, имеют несвязанную историю. Поэтому между ветвями нельзя строить обычные pull request или merge. В отличие от этого fork сохраняет историю родительского репозитория.

\n

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

\n

Порядок принятия решения

\n
  1. Назовите класс. Одним предложением опишите компонент, его owner, runtime и класс данных. Слова «обычно» и «иногда» отмечают место, где граница ещё не доказана.
  2. Закройте вход. Перечислите обязательные поля и допустимые значения. Пустой owner, неизвестный runtime и новая policy должны иметь явный отказ.
  3. Опишите инварианты. Запишите, что останется истинным после генерации: тип компонента, обязательные метаданные, проверки и границы доступа.
  4. Отделите output от approval. Укажите, что создаёт шаблон, а какие решения принимаются следующим review. Generated файл не является доказательством запуска.
  5. Проверьте отклонение. Для одного варианта назовите extension, owner, условие входа, изменяемые артефакты и rollback. Если отклонение меняет класс, откройте новый путь.
  6. Проверьте механизм доставки. Независимый старт допускает template repository. Долгоживущая связь с общим кодом требует зависимости или процесса обновления, который можно проверить.
  7. Назначьте приёмку. Пусть отдельный участник сверит входы, созданные файлы, metadata, права и обязательные проверки. Положительный ответ открывает следующий этап, но не заменяет его.
  8. Зафиксируйте версию. Изменение смысла входа оформляйте новой версией или новым контрактом. Не меняйте старый default так, чтобы уже созданные проекты получили другой смысл задним числом.
\n

Когда отказ экономит время

\n

Разовая миграция регулируемых данных обычно не подходит для общего service template. В ней могут быть неизвестны владелец данных и lifecycle, а access и retention требуют отдельного согласования. Если спрятать эти вопросы в форму, пользователь получит аккуратный skeleton, но архитектурная ответственность останется неразрешённой.

\n

Отказ возвращает задачу в правильную границу: design record, security review или обсуждение владельца данных. После нескольких одинаковых решений может появиться новый класс. Тогда его оформляют отдельно — с собственным owner, входами, инвариантами, источниками и rollback. Одной удачной заявкой такой класс не доказывается.

\n

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

\n

Платформенный шаблон не заменяет threat modeling, capacity planning, тесты, CI, миграционный план и проверку прав. Он не гарантирует качество созданного сервиса и не откатывает изменения, внесённые командой после генерации. Для draft можно удалить draft; для extension нужен описанный способ отключения; для уже созданного репозитория отдельно определяют, возвращаются ли файлы, зависимость, конфигурация или данные.

\n

Шаблон готов к применению, если второй инженер без устных пояснений может показать класс, owner, версию, обязательные входы, инварианты, output, refusal criterion и rollback. Для каждой интеграции нужно дополнительно проверить установленную версию Backstage, разрешения publish action, целевое хранилище и правила вашей организации. Учебная команда выше подтверждает только отрицательную ветку в памяти Node.js; она не подтверждает живой scaffolder или production readiness.

\n

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

\n" } diff --git a/editorial/agent-rewrites/133.json b/editorial/agent-rewrites/133.json index c95ec19..433f3f9 100644 --- a/editorial/agent-rewrites/133.json +++ b/editorial/agent-rewrites/133.json @@ -2,6 +2,6 @@ "index": 133, "slug": "editorial-2024-04-field-data-migrations", "title": "Миграция данных без ловушки: совместимость, backfill и безопасный contract", - "excerpt": "Новая форма данных не становится безопасной от одного успешного deploy. Разбираем expand/migrate/contract, ограниченный backfill и признаки, по которым нужно остановить удаление старого представления.", - "contentHtml": "

Симптом обычно появляется после deploy: новая версия сервиса читает новое поле, а часть записей всё ещё хранит старую форму. Затем backfill начинает нагружать базу, а старый worker продолжает писать только старое представление. В логах растёт доля fallback-чтений, обработка очереди замедляется, а команда уже обсуждает удаление старой колонки. Цена ошибки — не только откат релиза. Код можно вернуть, но уже записанные данные не обязаны вернуться в прежнюю форму. Пользователь увидит пустое значение, а восстановление потребует отдельного data repair.

\n

Тезис простой: миграция данных — это не одна команда DDL и не один зелёный deploy. Сначала нужно сохранить совместимость версий, затем ограниченно перенести данные, после этого доказать готовность нового чтения и только в конце удалить старую форму. Каждый переход должен иметь собственную проверку. Если хотя бы один потребитель неизвестен, старое представление остаётся.

\n

Механизм: expand, migrate, switch, contract

\n

В старой системе заказ хранится в полях status и amount. Новая версия хочет хранить объект summary. На первом шаге схема получает новую форму, но старый writer не должен ломаться. Новый reader принимает обе формы. Новый writer временно записывает обе. Это expand.

\n

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

\n

Затем система переключает чтение на новую форму. Fallback к старой форме ещё нужен, пока не проверены старые записи, отложенные worker-ы и все читатели. Успешное чтение новой записи не доказывает, что старых потребителей больше нет. Switch опирается на наблюдаемые данные, а не на дату релиза.

\n

Contract — отдельное решение. Старую форму можно удалить только после подтверждения, что старый reader и writer больше не участвуют, backfill завершён с понятным критерием, а восстановление не зависит от удаляемых данных. Если условие не доказано, contract откладывают. Это отрицательный путь, а не неполная миграция.

\n

Учебный пример совместимости

\n

Ниже — ограниченный пример на JavaScript. Он проверяет только заявленные версии и формы. Функция не обращается к базе, не запускает SQL и не измеряет нагрузку. Поэтому результат stop означает «не переходить к следующему этапу в этом сценарии», а не verdict для production.

\n
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

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

\n
Диагностика перехода между формами данных
СимптомПричинаПроверкаДействие
Новая форма есть только у части записейBackfill ещё не закончен или новые записи обходят dual writeСравнить доли old/new по времени записи и источникуОставить fallback, остановить contract, исправить writer
Backfill замедляет рабочие запросыШирокий scope, слишком большая порция или конкурирующая нагрузкаПроверить latency, lock wait, размер batch и границу выборкиОстановить job, сузить scope и определить лимит до нового запуска
Старая версия получает ошибку записиSchema constraint введён раньше совместимого writerВоспроизвести запись v1 на тестовой копии и проверить порядок deployВернуть совместимое расширение, не маскировать ошибку retry
После deploy растёт fallbackReader видит old data или новый writer не заполнил полеРазделить fallback по версии, endpoint и типу записиСохранить старую ветку и найти источник несовместимых записей
Все тесты зелёные, но consumer неизвестенТест проверяет сценарий, а не весь fleetСверить владельцев, worker-ы, cron, batch и старые clientsНе удалять старую форму до найденного доказательства
Нужен срочный rollback после очисткиУдаление данных ошибочно назвали обратимымПроверить backup, retention и возможность read-back старой формыПерейти к data repair или restore-плану, не обещать обычный rollback
\n

Иллюстрация перехода

\n
\"Переход
Существующая схема показывает контрольные точки миграции. Gate не запускает операцию и не подтверждает production-готовность: он фиксирует вопросы, на которые должны ответить реальные данные и владельцы системы.
\n

Иллюстрация полезна именно как граница ответственности. Совместимость версий проверяет контракт приложения. Backfill проверяет состояние данных и нагрузку. Contract проверяет отсутствие зависимости от старой формы. Ни один этап не доказывает остальные.

\n

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

\n
  1. Опишите old reader, old writer, new reader и new writer. Для каждой пары запишите, какую форму она читает и пишет.
  2. Сделайте expand совместимым: добавьте новую форму так, чтобы допустимый старый writer не получил отказ. Отдельно проверьте constraints, default, trigger и порядок deploy для вашей СУБД.
  3. Включите dual write только там, где можно определить поведение при частичной ошибке. Если две записи не входят в одну транзакционную границу, опишите reconciliation.
  4. Задайте backfill scope, batch boundary, owner, повторный запуск и stop signal. Перед стартом назовите состояние данных после остановки.
  5. Запустите ограниченную проверку на разрешённой среде. Сравните old и new representation, ошибки, пропуски, время обработки и влияние на рабочий трафик.
  6. Переключайте чтение по evidence. Оставьте fallback и сигнализируйте его использование, пока старые записи и потребители не проверены.
  7. Отдельно подтвердите отсутствие old consumer. Проверьте код, расписания, очереди, фоновые задачи, batch-процессы и внешние клиенты.
  8. Составьте recovery boundary. Укажите, что возвращает deploy, что восстанавливается из данных и в какой момент нужен restore или repair.
  9. Удаляйте старую форму последней операцией. Если один критерий не выполнен, остановитесь на migrate или switch и зафиксируйте причину.
\n

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

\n

Expand/contract не делает миграцию беспростойной. Dual write может дать расхождение, если запись в одну систему прошла, а в другую нет. Backfill может конкурировать с индексами, блокировками и репликацией. Внешний клиент может использовать старое поле без регистрации. ORM может добавить собственный cache или изменить порядок чтения. Эти случаи требуют проверки конкретной системы.

\n

Не переносите синтаксис PostgreSQL на другую СУБД. Даже в PostgreSQL команда, которая добавила constraint, не равна доказательству, что все старые строки уже проверены. Не считайте зелёный тест доказательством надёжности. Не увеличивайте batch, если неизвестна причина нагрузки. Не запускайте contract после одного удачного прогона. Если обнаружили несовместимость, правильное действие — остановить переход и сохранить старую форму.

\n

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

\n

Миграция готова к contract только тогда, когда одновременно выполнены пять условий: все допустимые версии читают нужную форму; writer-ы не создают неподдерживаемые записи; backfill имеет завершённый scope и повторяемый результат; использование old representation и fallback равно нулю в согласованном окне наблюдения; recovery-план проверен для оставшейся границы риска. Число и длительность окна должны определить владельцы системы по своим SLO и traffic profile. В этой статье они не выдумываются.

\n

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

\n

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

\n" + "excerpt": "Новая колонка не становится безопасной от одного deploy. Разбираем полевой кейс: старый writer ещё жив, backfill не имеет границы остановки, а удаление прежней формы нельзя объявлять rollback-операцией.", + "contentHtml": "

Симптом появляется сразу после deploy: новая версия сервиса читает поле summary, а старый worker продолжает писать только status и amount. Часть строк уже имеет новую форму, часть — нет. Затем backfill начинает конкурировать с рабочими запросами, и кто-то предлагает удалить старую колонку, чтобы «закрыть миграцию». Это не один дефект схемы, а смешение трёх состояний: код ещё совместим не со всеми данными, перенос не имеет управляемой границы, а очистку принимают за обратимую операцию.

\n

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

\n

Кейс: схема уже изменилась, а writer — ещё нет

\n

Представим таблицу orders. До изменения приложение хранит короткий итог заказа в двух колонках:

\n
status = 'paid'\namount = 125000
\n

Новая версия хочет единый объект summary, например { status: 'paid', amountCents: 125000 }. Схему расширили, но старый сервис не знает о новом поле. Если новая колонка сразу станет обязательной, старый writer начнёт получать отказ. Если сделать её nullable и сразу переключить reader, старые записи могут превратиться в пустой экран. Если запустить backfill без границы, проблема совместимости сменится проблемой нагрузки.

\n

Первое действие — составить список участников. В него входят HTTP-сервисы, workers, cron-задачи, импортёры, отчёты и внешние клиенты. Для каждого нужно записать версию, что он читает и что пишет. «Мы нашли все вызовы» — гипотеза, пока её не сверили с кодом, расписаниями, очередями и логами. Один забытый worker делает ранний contract опасным даже при зелёных тестах основного сервиса.

\n

Expand: совместимость важнее красивой новой схемы

\n

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

\n

Слово «nullable» описывает только одно свойство схемы. Оно не отвечает, что означает отсутствие значения: запись ещё не перенесена, поле к этому заказу неприменимо или преобразование потеряло данные. Эти три случая нельзя смешивать одним NULL. Если смысл отсутствия не определён, reader будет вынужден угадывать, а fallback превратится в скрытый источник расхождений.

\n

Для PostgreSQL есть отдельная полезная граница. В документации PostgreSQL 16 указано, что constraint можно добавить как NOT VALID: существующие строки не сканируются сразу, но новые вставки и обновления уже проверяются. Позже VALIDATE CONSTRAINT сканирует таблицу и подтверждает условие; команда получает SHARE UPDATE EXCLUSIVE lock. Это пример различия между «правило добавлено» и «все старые строки ему соответствуют». Он относится к PostgreSQL 16 и не переносит поведение на MySQL, другую версию, ORM или managed service.

\n

Матрица reader/writer для переходного периода

\n

Матрица помогает обнаружить несовместимую пару до запуска данных. В ней нет попытки описать весь 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 больше не нужны
\n

Таблица показывает важное ограничение: успешный v2 reader не доказывает отсутствие v1 writer. Аналогично, заполненные новые строки не доказывают, что отложенная очередь или внешний импортёр перестали писать старую форму. Поэтому переходы reader, writer и schema cleanup не стоит объединять в один release.

\n

Migrate: backfill должен иметь размер и остановку

\n

Backfill — это рабочий процесс, который читает старые строки и создаёт для них новую форму. У него должны быть scope, размер порции, порядок обхода, повторный запуск, владелец и stop condition. Scope отвечает на вопрос «какие строки мы переносим», stop condition — «когда прекращаем работу даже при неполном результате». Без второго параметра команда не умеет безопасно остановиться: при росте latency остаётся только спорить, продолжать ли job.

\n

Для большой таблицы полезен ключ-граница, а не неопределённое «обработать всё». Например, один запуск может работать с диапазоном идентификаторов до заранее записанного upperBound. Следующий запуск берёт новый диапазон после проверки результата. Но сам диапазон не гарантирует безопасность: нужно измерить lock wait, время запроса, lag реплики, ошибки и долю расхождений в выбранной среде. Порог нельзя взять из этой статьи, потому что его задают размер таблицы, индексы, traffic profile и SLO конкретной системы.

\n

GitLab в официальном руководстве разделяет schema migration и batched background migration: большие изменения данных выносятся в пакетную фоновую обработку, а схема меняется отдельно. Это полезное правило организации работ, но не готовая команда для чужого стека. Для PostgreSQL, Rails, очереди и версии GitLab нужны собственные ограничения и проверки.

\n

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

\n

Ниже — самостоятельный Node.js-скрипт. Он не подключается к базе: в массиве зафиксированы три записи, одна из которых уже имеет summary. Скрипт считает, что запись с новой формой можно пропустить, а старую — преобразовать. Если в отчёте остались ошибки преобразования или не задана граница диапазона, он возвращает stop. Это пример проверки структуры плана, не измерение производительности и не разрешение на production backfill.

\n
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-полем.

\n

Switch: новое чтение должно иметь наблюдаемый критерий

\n

После backfill читатель можно переключать постепенно. Сначала v2 умеет прочитать old и new, затем для выбранной области сравниваются результаты двух представлений. Расхождение нужно считать отдельно от обычной ошибки запроса: иначе fallback будет выглядеть как успешный ответ. Полезные поля наблюдения — версия reader, endpoint, идентификатор операции, причина fallback и направление записи. Чувствительные значения в лог не попадают.

\n

Нельзя заменить evidence календарём. Для переключения нужны хотя бы завершённый scope выбранной области, понятная доля fallback, отсутствие новых ошибок преобразования и подтверждённый список старых writers. Окно наблюдения и численные пороги определяет владелец сервиса по своему SLO. В этом тексте нет выдуманного «24 часа без ошибок»: для одной системы это может быть мало, для другой — не иметь смысла из-за недельной периодичности batch.

\n

Google SRE Book формулирует границу шире: тесты проверяют конкретные области эквивалентности и уменьшают неопределённость после изменения, но зелёный тест не доказывает надёжность всей системы. Поэтому rehearsal и тесты сравнения — аргументы для switch, а не автоматический приказ удалить старую форму.

\n

Contract: удаление — отдельное и иногда необратимое решение

\n

Contract начинается тогда, когда старое представление больше не нужно ни одному допустимому читателю и writer-у. До этого момента его можно сделать невидимым для нового пути, но не следует физически удалять. Сначала прекращают старые записи, затем подтверждают отсутствие old consumer, потом удаляют код fallback и только после этого рассматривают удаление колонки или таблицы. На каждом шаге нужен read-back состояния.

\n

Rollback к прежнему binary возвращает код, а не обязательно данные. Если contract уже удалил колонку, очистил старую таблицу или преобразовал значение с потерей информации, восстановление потребует data repair или restore из резервной копии. Поэтому в runbook полезно разделить три границы: что откатывает deploy, что восстанавливает application path и что требует восстановления данных. Слово «rollback» без этого разделения создаёт ложное чувство безопасности.

\n
Схема перехода данных: mixed fleet проходит через проверку совместимости, ограниченный backfill и stop gate; при неизвестном старом потребителе стрелка останавливается перед contract
Stop gate не запускает миграцию и не читает production-метрики. Он фиксирует условия, которые должны быть доказаны в конкретной системе до удаления старой формы.
\n

Порядок проверки перед удалением

\n
  1. Опишите old и new representation. Отдельно зафиксируйте смысл отсутствующего значения, правило преобразования и случаи потери данных.
  2. Составьте матрицу версий reader/writer. Неизвестную клетку пометьте как blocker, а не заполняйте предположением.
  3. Сделайте expand, который не ломает разрешённый старый writer. Проверьте constraints, defaults, triggers, индексы и порядок deploy по документации вашей СУБД.
  4. Опишите backfill как bounded job: scope, ключ-граница, batch, повторный запуск, owner, stop condition и ожидаемое partial state.
  5. Проведите rehearsal на копии или разрешённом стенде с тем же типом данных. Проверьте conversion error, retry, остановку и запись прогресса.
  6. Включите метрики fallback и divergence. Разделите их по reader, writer, endpoint и типу записи, чтобы одна успешная ветка не скрыла старую.
  7. Переключайте чтение постепенно. Оставьте совместимый fallback до завершения согласованного окна и read-back результата.
  8. Отдельно проверьте workers, cron, очереди, импорты и внешние clients. Удаление старого consumer из основного репозитория не закрывает весь fleet.
  9. Запишите recovery boundary. Для каждого шага укажите, возвращается ли кодом, исправляется ли данными или требует restore.
  10. Рассмотрите contract последним. Если backfill не ограничен, старый consumer неизвестен или recovery не проверен, остановитесь на текущем шаге.
\n

Ограничения: где этот алгоритм не даёт готового рецепта

\n

Expand/migrate/switch/contract не решает автоматически dual write между двумя базами. Если запись в одну систему подтверждена, а во вторую нет, потребуется reconciliation или другая архитектура согласованности. ORM может кэшировать старую форму. Реплика может отставать. Очередь может повторить сообщение. Внешний клиент может использовать старое поле без регистрации. Для каждого случая нужно отдельно определить идемпотентность, порядок событий и источник истины.

\n

Пример SQL из документации PostgreSQL нельзя переносить на другую СУБД. Даже в PostgreSQL NOT VALID не означает, что старые строки уже соответствуют constraint; это состояние до отдельной валидации. Материал Stripe — описание миграции Stripe с их хранилищем и инструментами, а не обещание нулевого простоя в вашем проекте. Источник Google объясняет роль тестов, но не вычисляет допустимый batch или capacity базы.

\n

Если наблюдение показывает рост lock wait, lag, ошибок преобразования или divergence, остановка — ожидаемый результат контроля. Сначала сохраняют старую форму, фиксируют частичный прогресс и выясняют причину. Увеличить параллелизм или удалить fallback без этой проверки значит расширить blast radius, а не ускорить завершение.

\n

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

\n

Contract можно рассматривать только при одновременном выполнении пяти условий: все разрешённые версии читают совместимую форму; writer-ы не создают неподдерживаемые строки; backfill завершил явно заданный scope и даёт повторяемый результат; fallback и divergence наблюдаются в согласованном окне с результатом, который устраивает владельца; recovery boundary проверена для каждого необратимого шага.

\n

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

\n

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

\n" } diff --git a/editorial/agent-rewrites/134.json b/editorial/agent-rewrites/134.json index 3017a2f..c50090d 100644 --- a/editorial/agent-rewrites/134.json +++ b/editorial/agent-rewrites/134.json @@ -3,5 +3,5 @@ "slug": "editorial-2024-04-mechanism-data-migrations", "title": "Миграция данных без разрыва контракта: expand, backfill и граница отката", "excerpt": "Как провести изменение формы данных при смешанных версиях приложения: сохранить совместимость старых reader и writer, ограничить backfill и не принять удаление старой формы за rollback.", - "contentHtml": "

После релиза новая версия сервиса получает записи без нового поля. В логах растёт число ошибок валидации, а старый 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: добавить новую форму, не сломав старую

На expand создают колонку, таблицу или индекс, который не требует от старого кода неизвестного значения. Новая поверхность может быть nullable, но nullable не означает безопасно. Нужно описать, что значит отсутствие, какие записи допустимы и когда значение станет обязательным. Если old writer не способен сохранить новый инвариант, его нельзя превратить в ошибку одним DDL-шагом.

Стоимость операции зависит от движка, версии и объекта. В документации PostgreSQL описаны разные последствия для добавления колонки, default и ограничений. Поэтому нельзя переносить обещание «без блокировки» с одной СУБД на другую. Перед запуском проверяют конкретную операцию на выбранной версии движка и учитывают её влияние на размер таблицы, lock и репликацию.

\"Матрица
Матрица показывает допустимый переход версий. Она не подтверждает, какие версии реально запущены, и не измеряет полноту данных.

Migrate: backfill с бюджетом и стоп-сигналом

Backfill — отдельный поток изменения состояния. Для него нужны область записей, размер управляемой порции, правило повторного запуска, owner, журнал результата и условие остановки. «Запустить джобу до конца» не является планом. Конец может не наступить, а повторный запуск может дважды применить преобразование.

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

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

Switch и contract: два разных решения

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 отдельно отмечает дополнительную стоимость записи и постепенное увеличение нагрузки. В собственном проекте эти параметры нужно измерять отдельно.

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

  1. Опишите old и new shape. Назовите обязательные поля, значение отсутствия, правила преобразования и случаи, которые нельзя восстановить.
  2. Составьте матрицу old/new reader и writer. Включите сервисы, worker-ы, очереди, cron и внешних потребителей. Неизвестную роль пометьте как blocker.
  3. Выберите expand, который сохраняет работу declared old writer. Проверьте DDL, lock и поведение конкретной версии СУБД по официальной документации.
  4. Выпустите совместимый reader и writer. Убедитесь, что fallback виден в коде и наблюдении, а dual write имеет понятное правило повторения.
  5. Оформите backfill: scope, owner, bounded batch, idempotency, stop signal, partial state и действие после остановки.
  6. Проведите ограниченную проверку mixed-version сценария. Зафиксируйте только проверенный результат; не называйте учебный или изолированный прогон доказательством production-ready.
  7. Переключите reader по заранее названному evidence. Оставьте старую форму доступной на период наблюдения.
  8. Проверьте отсутствие старых consumers, расхождения данных и необработанной области. Только после этого принимайте отдельное решение о contract.

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

Эта модель не выбирает 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-данных.

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

" + "contentHtml": "

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

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

Сначала фиксируем контракт во времени

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

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

\"Матрица
Матрица показывает проектный переход: старый путь ещё объявлен, переходная версия читает и пишет обе формы, а удаление старой формы начинается только после доказательства.

Четыре этапа нельзя смешивать

Expand добавляет новую поверхность: колонку, таблицу или индекс. Старый код после этого всё ещё должен работать. Backfill переносит уже существующие записи ограниченными порциями. Switch меняет основной путь чтения, а затем, при необходимости, записи. Contract удаляет старый путь и старые данные. У каждого этапа свой сигнал готовности и свой способ остановки.

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

Совместимый reader и явный отрицательный путь

Переходный 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

Ниже — небольшой 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-процедуру

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

Expand: новая поверхность без обязательства для старого кода

Для 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: ограничиваем работу и оставляем след

У backfill должны быть scope, владелец, размер порции, критерий повторного запуска, журнал обработанных строк и стоп-сигнал. Минимальный журнал фиксирует время, диапазон или набор идентификаторов, количество успешно обработанных и количество пропущенных записей. Без этого остановка превращается в догадку.

Повторяемость не равна идемпотентности. Условие money IS NULL делает показанный пример повторяемым для незаполненных строк, но не решает конфликт, когда старое значение изменилось после первой записи. Для таких строк нужен version check, блокировка, журнал событий или ручная процедура — выбор зависит от writer-а и модели консистентности.

Стоп-сигналом может быть согласованный рост p95, ошибки, lock wait, saturation или расхождение контрольной выборки. Порог получает команда для конкретной среды; в статье нет измерения, из которого его можно вывести. Остановка должна сохранить partial state и ответ на три вопроса: что обработано, что осталось и какой запуск безопасен следующим.

Switch не равен contract

Перед 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.

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

  1. Опишите old и new shape: обязательные поля, смысл отсутствия и случаи, которые нельзя восстановить.
  2. Составьте матрицу old/new reader и writer. Включите worker-ы, cron, очереди и внешних consumers; неизвестную роль пометьте как blocker.
  3. Проверьте expand на копии или тестовой базе: DDL, lock, default, constraint и размер объекта для конкретной версии PostgreSQL.
  4. Выпустите совместимый reader и writer. Зафиксируйте правило dual write, конфликтов и повторной операции.
  5. Оформите backfill: scope, owner, bounded batch, идемпотентность, stop signal, partial state и действие после остановки.
  6. Прогоните четыре mixed-version случая: старая форма, новая форма, неполная форма и конфликтующие значения.
  7. Переключите reader по заранее названному evidence, оставив старую форму на период наблюдения.
  8. Проверьте consumers, расхождения и необработанную область. Только затем принимайте отдельное решение о contract и recovery boundary.

Границы применимости

Модель подходит для совместимых изменений формы данных, когда старый и новый контракт могут сосуществовать. Она не выбирает 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-у.

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

" } diff --git a/editorial/agent-rewrites/135.json b/editorial/agent-rewrites/135.json index b559c96..6991806 100644 --- a/editorial/agent-rewrites/135.json +++ b/editorial/agent-rewrites/135.json @@ -3,5 +3,5 @@ "slug": "editorial-2024-04-practice-data-migrations", "title": "Миграция данных без ловушки отката: expand, migrate, contract", "excerpt": "Как менять схему при смешанных версиях приложения: сохранить совместимость, ограничить backfill, проверить отрицательный путь и не принять удаление старого поля за обычный rollback.", - "contentHtml": "

После релиза новая версия сервиса отвечает ошибкой на запись: обязательное поле ещё не заполняет старый writer. В другой попытке поле сделали nullable, запустили backfill без предела и получили рост задержек. В обоих случаях DDL прошло успешно. Ошибка появилась позже, когда версии приложения стали жить рядом. Цена ошибки — потерянные записи, очередь повторных запросов и отсутствие честного пути назад. Откат бинарника не возвращает данные, которые уже перезаписаны или удалены.

\n

Безопасная миграция — это не одна команда изменения схемы. Это период совместимости между old и new reader, old и new writer, затем отдельное преобразование данных и только потом удаление старой формы. Такой маршрут называют expand–migrate–contract. Он не делает операцию безопасной автоматически. Он раскладывает риск на этапы, для каждого этапа задаёт проверку и оставляет границу, после которой rollback приложения уже недостаточен.

\n

Механизм совместимости

\n

Представьте запись заказа. Старая форма хранит имя клиента в поле customer_name. Новая форма должна хранить ссылку customer_id. Если сразу удалить старое поле, старый сервис перестанет писать. Если сразу потребовать customer_id, старые строки и старые workers станут ошибками. Поэтому сначала добавляют новую поверхность, не запрещая старую.

\n

На этапе 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 до переключения.
\n

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

\n

Expand: добавить, не отрезать

\n

Expand меняет структуру так, чтобы old code продолжал работать. Это может быть nullable-колонка, новая таблица или новый индекс. Конкретная операция зависит от СУБД. Нельзя переносить обещание «добавление поля быстро» с одной версии и одного типа default на другую. В PostgreSQL поведение ALTER TABLE, блокировки и переписывание таблицы зависят от команды и параметров. Проверяйте документацию именно своей версии.

\n

Для примера с заказом expand добавляет customer_id, но не удаляет customer_name и не делает ссылку обязательной для старых строк. New reader использует customer_id, если он есть, иначе временно читает старое имя. Такой fallback должен иметь owner и условие удаления. Иначе временный путь станет постоянным и команда не поймёт, закончена ли миграция.

\n
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

Migrate: управляемый backfill

\n

Backfill отвечает на вопрос «как преобразовать старые строки». Он не должен менять договор совместимости. Запускайте его как отдельный процесс с областью, cursor, размером партии, лимитом скорости, idempotency-правилом и измеримым стоп-сигналом. Партия из тысячи строк не является безопасной сама по себе. Она может запускаться без конца, конкурировать с пользовательскими запросами или повторно менять одну строку после сбоя.

\n

Идемпотентный шаг проверяет текущее состояние перед записью. Если строка уже имеет корректный customer_id, повторный запуск её пропускает. Если соответствие неоднозначно, job должна остановиться или отправить строку на ручной разбор. Она не должна выбирать первый результат молча. В миграции данных неопределённость — это сигнал остановки, а не повод увеличить batch.

\n

Учебный псевдокод ниже ограничен памятью процесса. Он не читает настоящую БД, не измеряет locks, не запускает транзакции и не сообщает о готовности production.

\n
for (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

Switch: сначала readers, затем writers

\n

Переключение чтения не равно завершению backfill. Оно означает, что new reader умеет обработать остаток старой формы и команда согласовала, что делать с null, конфликтом и повторной записью. После переключения наблюдайте ошибки, долю fallback, расхождения двух форм и время обработки. Не удаляйте старое поле сразу после первого зелёного графика.

\n

Пока old writer или долгоживущий worker ещё может работать, new writer должен сохранять совместимость. Это может быть dual write, событие для отдельного consumer или другой явно описанный механизм. Dual write тоже создаёт риск: записи могут завершиться только в одной форме, а порядок событий может расходиться. Поэтому нужна проверка расхождений и правило исправления. Сам термин dual write ничего не гарантирует.

\n
\"Временная
Схема показывает порядок решений. Старая форма сохраняется через expand, migrate и switch. Contract допускается только после проверки consumers и recovery plan. Иллюстрация не показывает реальный deployment, нагрузку или результат production-операции.
\n

Stripe описывает похожий четырёхэтапный путь для своей онлайн-миграции: dual write, перевод чтений, перевод записей и удаление старых данных. Это инженерный разбор инфраструктуры Stripe, а не универсальная гарантия. В другой системе нужно отдельно проверить объём, lock behavior, задержки, ретраи и все пути записи.

\n

Contract: граница невозврата

\n

Contract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это полезный финал, но не обычный rollback. Возврат к старому бинарнику восстановит код, а не удалённые значения. Если старое представление нужно для восстановления, его сохраняют до contract: backup проверяют восстановлением, копию снабжают сроком хранения, а divergence связывают с понятным действием.

\n

Не называйте contract готовым по одному признаку. Green build не знает о ручном SQL-клиенте. Нулевой fallback за минуту не доказывает, что вчерашний worker завершился. Полная проверка должна охватывать writers, readers, jobs, очереди, отчёты и восстановление. Если список consumers неполон, безопасное действие — продлить совместимый период.

\n

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

\n
  1. Опишите old и new shape. Назовите owner данных, readers, writers, допустимый null и правило разрешения конфликта.
  2. Проверьте операцию expand в документации своей СУБД. Узнайте блокировки, rewrite, ограничения версии и размер затрагиваемого объекта.
  3. Добавьте новую структуру без отказа old writer. Выпустите reader, который понимает обе формы и явно обрабатывает отсутствие данных.
  4. Оформите backfill: область, cursor, batch, concurrency, idempotency, журнал ошибок, stop signal и действие после частичного прогресса.
  5. Проведите ограниченный rehearsal на изолированных данных. Проверьте mixed versions, повторный запуск и остановку на неоднозначной записи.
  6. Соберите evidence для switch: список consumers, долю старой формы, ошибки, расхождения и результат восстановления резервной копии.
  7. Переключите readers, затем writers. Оставьте fallback и dual write на согласованный период наблюдения.
  8. Отдельно одобрите contract. Если жив старый consumer, неизвестно, как восстановить данные, или нет критерия остановки, contract не выполняйте.
\n

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

\n

Expand–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

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

\n" + "contentHtml": "

После релиза новая версия сервиса отвечает ошибкой на запись: обязательное поле ещё не заполняет старый writer, то есть компонент, который сохраняет запись. В другой попытке поле сделали nullable, запустили backfill без предела и получили рост задержек. В обоих случаях DDL прошло успешно. Ошибка появилась позже, когда версии приложения стали жить рядом. Цена ошибки — потерянные записи, очередь повторных запросов и отсутствие честного пути назад. Откат бинарника не возвращает данные, которые уже перезаписаны или удалены.

\n

Безопасная миграция — это не одна команда изменения схемы. Это период совместимости между старым и новым reader (компонентом чтения), старым и новым writer, затем отдельное преобразование данных и только потом удаление старой формы. Такой маршрут называют expand–migrate–contract. Он не делает операцию безопасной автоматически. Он раскладывает риск на этапы, для каждого этапа задаёт проверку и оставляет границу, после которой rollback приложения уже недостаточен.

\n

Механизм совместимости

\n

Представьте запись заказа. Старая форма хранит имя клиента в поле customer_name. Новая форма должна хранить ссылку customer_id. Если сразу удалить старое поле, старый сервис перестанет писать. Если сразу потребовать customer_id, старые строки и старые workers станут ошибками. Поэтому сначала добавляют новую поверхность, не запрещая старую.

\n

На этапе 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 до переключения.
\n

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

\n

Expand: добавить, не отрезать

\n

Expand меняет структуру так, чтобы old code продолжал работать. Это может быть nullable-колонка, новая таблица или новый индекс. Конкретная операция зависит от СУБД. Нельзя переносить обещание «добавление поля быстро» с одной версии и одного типа default на другую. В PostgreSQL поведение ALTER TABLE, блокировки и переписывание таблицы зависят от команды и параметров. Проверяйте документацию именно своей версии.

\n

Для примера с заказом expand добавляет customer_id, но не удаляет customer_name и не делает ссылку обязательной для старых строк. New reader использует customer_id, если он есть, иначе временно читает старое имя. Такой fallback должен иметь owner и условие удаления. Иначе временный путь станет постоянным и команда не поймёт, закончена ли миграция.

\n
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

Воспроизводимая проверка на PostgreSQL 16

\n

Ниже — самостоятельная демонстрация для временных таблиц. Её можно вставить в psql PostgreSQL 16: она не обращается к рабочей схеме, добавляет nullable-колонку, переносит две строки и показывает остаток старой формы. Имена клиентов здесь используются только для учебного соответствия; в реальной миграции совпадение должно опираться на устойчивый уникальный ключ.

\n
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. Если имя не сопоставляется однозначно, строку нельзя заполнять случайным совпадением.

\n

Migrate: управляемый backfill

\n

Backfill отвечает на вопрос «как преобразовать старые строки». Он не должен менять договор совместимости. Запускайте его как отдельный процесс с областью, cursor, размером партии, лимитом скорости, idempotency-правилом и измеримым стоп-сигналом. Партия из тысячи строк не является безопасной сама по себе. Она может запускаться без конца, конкурировать с пользовательскими запросами или повторно менять одну строку после сбоя.

\n

Идемпотентный шаг проверяет текущее состояние перед записью. Если строка уже имеет корректный customer_id, повторный запуск её пропускает. Если соответствие неоднозначно, job должна остановиться или отправить строку на ручной разбор. Она не должна выбирать первый результат молча. В миграции данных неопределённость — это сигнал остановки, а не повод увеличить batch.

\n

Учебный псевдокод ниже ограничен памятью процесса. Он не читает настоящую БД, не измеряет locks, не запускает транзакции и не сообщает о готовности production.

\n
for (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

Switch: сначала readers, затем writers

\n

Переключение чтения не равно завершению backfill. Оно означает, что new reader умеет обработать остаток старой формы и команда согласовала, что делать с null, конфликтом и повторной записью. После переключения наблюдайте ошибки, долю fallback, расхождения двух форм и время обработки. Не удаляйте старое поле сразу после первого зелёного графика.

\n

Пока old writer или долгоживущий worker ещё может работать, new writer должен сохранять совместимость. Это может быть dual write, событие для отдельного consumer или другой явно описанный механизм. Dual write тоже создаёт риск: записи могут завершиться только в одной форме, а порядок событий может расходиться. Поэтому нужна проверка расхождений и правило исправления. Сам термин dual write ничего не гарантирует.

\n
\"Временная
Схема показывает порядок решений. Старая форма сохраняется через expand, migrate и switch. Contract допускается только после проверки consumers и recovery plan. Иллюстрация не показывает реальный deployment, нагрузку или результат production-операции.
\n

Stripe описывает похожий четырёхэтапный путь для своей онлайн-миграции: dual write, перевод чтений, перевод записей и удаление старых данных. Это инженерный разбор инфраструктуры Stripe, а не универсальная гарантия. В другой системе нужно отдельно проверить объём, lock behavior, задержки, ретраи и все пути записи.

\n

Contract: граница невозврата

\n

Contract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это полезный финал, но не обычный rollback. Возврат к старому бинарнику восстановит код, а не удалённые значения. Если старое представление нужно для восстановления, его сохраняют до contract: backup проверяют восстановлением, копию снабжают сроком хранения, а divergence связывают с понятным действием.

\n

Не называйте contract готовым по одному признаку. Green build не знает о ручном SQL-клиенте. Нулевой fallback за минуту не доказывает, что вчерашний worker завершился. Полная проверка должна охватывать writers, readers, jobs, очереди, отчёты и восстановление. Если список consumers неполон, безопасное действие — продлить совместимый период.

\n

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

\n
  1. Опишите old и new shape. Назовите owner данных, readers, writers, допустимый null и правило разрешения конфликта.
  2. Проверьте операцию expand в документации своей СУБД. Узнайте блокировки, rewrite, ограничения версии и размер затрагиваемого объекта.
  3. Добавьте новую структуру без отказа old writer. Выпустите reader, который понимает обе формы и явно обрабатывает отсутствие данных.
  4. Оформите backfill: область, cursor, batch, concurrency, idempotency, журнал ошибок, stop signal и действие после частичного прогресса.
  5. Проведите ограниченный rehearsal на изолированных данных. Проверьте mixed versions, повторный запуск и остановку на неоднозначной записи.
  6. Соберите evidence для switch: список consumers, долю старой формы, ошибки, расхождения и результат восстановления резервной копии.
  7. Переключите readers, затем writers. Оставьте fallback и dual write на согласованный период наблюдения.
  8. Отдельно одобрите contract. Если жив старый consumer, неизвестно, как восстановить данные, или нет критерия остановки, contract не выполняйте.
\n

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

\n

Expand–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

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

\n" } diff --git a/editorial/agent-rewrites/136.json b/editorial/agent-rewrites/136.json index fa4321e..79e464d 100644 --- a/editorial/agent-rewrites/136.json +++ b/editorial/agent-rewrites/136.json @@ -3,5 +3,5 @@ "slug": "editorial-2024-03-field-package-boundaries", "title": "Границы пакетов: как остановить утечку домена в общую utility", "excerpt": "Общая utility начинает ломать архитектуру задолго до падения сборки: она узнаёт доменные типы, а consumers обходят public API через internal-файлы. Разбираем симптомы, проверку границы и безопасные варианты исправления.", - "contentHtml": "

Сборка проходит, тесты зелёные, но следующий небольшой import внезапно требует правок в трёх пакетах. Общая utility знает про доменный статус счёта. Feature импортирует её внутренний cache по файловому пути. После этого изменение enum затрагивает форматтер, а переименование cache ломает consumer. Цена ошибки — скрытая связанность, более длинные ревью и миграция, которую нельзя выполнить одним владельцем.

\n

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

\n

Два симптома одной потери договора

\n

Рассмотрим учебный пример. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.

\n

Первый 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
\n

Механизм: смысл движется вверх, детали — вниз

\n

Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он начинает зависеть не от формы входа, а от причины, по которой вход существует.

\n

У 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-коде.

\n

Как назвать public API

\n

Начните не с 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-а: без неё временная лазейка становится постоянной.

\n

После этого можно выбрать техническую проверку. В Node поле exports задаёт разрешённые entry points и subpaths для package resolution. TypeScript при подходящем moduleResolution учитывает этот контракт, но настройки должны соответствовать runtime или bundler. ESLint может ловить запрещённые static imports. Ни один из этих механизмов не отвечает за смысл InvoiceStatus и не доказывает, что dynamic loader соблюдает тот же договор.

\n
\"Учебный
Учебный маршрут: сначала отделить доменную утечку от обхода public API, затем проверить конкретный import. Иллюстрация не показывает реальный граф зависимостей или результат CI.
\n

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

\n
  1. Зафиксируйте симптом. Запишите точный module specifier и imported name из diff или заявки. Не заменяйте его формулой «пакеты сильно связаны».
  2. Назовите владельца смысла. Для каждого типа спросите, кто меняет его значения и правила отображения. Если ответ — billing, не переносите enum в formatting.
  3. Разделите направления. Отметьте, где utility зависит от domain, а где consumer зависит от internal. Это две записи и два решения, даже если они находятся в одном diff.
  4. Сверьте public record. Проверьте root specifier, разрешённое имя, запретный subpath, версии runtime и способ разрешения модулей.
  5. Выберите действие. Верните mapping владельцу домена, добавьте reviewed root export, создайте named adapter или отклоните deep import. Не оставляйте «разрешить пока» без даты.
  6. Проверьте отрицательный путь. Убедитесь, что неизвестное имя, internal subpath и новый доменный import действительно отклоняются выбранным guard-ом. Отдельно проверьте dynamic imports и generated code, если они есть.
  7. Повторите проверку после изменения. Сравните public surface до и после, запустите type check и lint в поддерживаемой конфигурации, затем проверьте потребителя. Synthetic пример не заменяет чтение реального графа.
\n

Что делать с cache и adapter-ом

\n

Если cache нужен только formatter-у, consumer должен вызывать public функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите её в отдельный reviewed export. Зафиксируйте входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.

\n

Adapter допустим, когда он имеет владельца и срок жизни. Например, старый consumer может временно вызывать formatMoneyAdapter, пока команда мигрирует на новый root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а проверка удаления — иметь конкретный сигнал.

\n

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

\n

Поле exports не делает любую архитектуру правильной. Внутренние и внешние пакеты отличаются по semver-обязательствам. Legacy consumers могут требовать переходный слой. TypeScript может разрешить типы в одной конфигурации, а runtime или bundler — разрешить их иначе. Поэтому проверяйте фактическую toolchain, а не только редактор и компилятор.

\n

Static rule не видит все способы загрузки кода. Dynamic import(), generated files и framework entry points требуют отдельного решения. Нельзя объявлять отсутствие lint-ошибки доказательством отсутствия зависимости. Нельзя и запрещать весь pattern без исключений: так adapter-ы получат suppressions, а реальные нарушения станут менее заметны.

\n

Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у. Если consumer уже использует internal path, не публикуйте весь каталог ради совместимости. Найдите операцию, которую consumer действительно требует, и оформите только её. Если такой операции нет, удалите зависимость и оставьте cache деталью владельца.

\n

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

\n

Работу можно считать готовой, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Дополнительно должны проходить type check и static guard в поддерживаемой конфигурации, а consumer должен использовать root API. Для временного adapter-а указаны owner, срок удаления и проверка его удаления.

\n

Критерий не требует доказать, что весь монорепозиторий свободен от domain leak. Он требует доказать одну согласованную границу на конкретном import-е. Это ограничение делает результат честным: учебный код показывает механизм, а реальный diff и выбранные проверки показывают состояние системы.

\n

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

" + "contentHtml": "

Сборка проходит, тесты зелёные, но следующий небольшой import внезапно требует правок в трёх пакетах. Общая utility знает про доменный статус счёта. Feature импортирует её внутренний cache по файловому пути. После этого изменение enum затрагивает форматтер, а переименование cache ломает consumer. Цена ошибки — скрытая связанность, более длинные ревью и миграция, которую нельзя выполнить одним владельцем.

\n

Граница пакета — не каталог и не слово shared. Это договор о доступных именах, смысле данных и запрещённых путях. Его можно проверить в исходниках, настройках разрешения модулей и статическом анализаторе. Но сначала нужно решить, кто владеет смыслом. Инструмент способен поймать deep import, но не способен определить, кому принадлежит правило «просроченный счёт».

\n

Симптомы и цена ошибки

\n

Рассмотрим синтетический кейс, чтобы не выдавать учебную схему за отчёт о production-системе. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.

\n

Первый 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
\n

Механизм: смысл остаётся у domain owner

\n

Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он зависит уже не от формы входа, а от причины, по которой вход существует.

\n

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

\n

У deep import другой механизм. Consumer начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета уже не может свободно переименовать cache, изменить invalidation или разнести реализацию по файлам. То, что bundler сегодня разрешает путь, ещё не делает его публичным.

\n
\"Учебный
Учебный маршрут: сначала отделить доменную утечку от обхода public API, затем проверить конкретный import. Иллюстрация не показывает реальный граф зависимостей или результат CI.
\n

Воспроизводимый пример без доменной зависимости

\n

Ниже функция запускается на Node.js без библиотек. Вход содержит сумму в минимальных единицах и код валюты. Смысл статуса выбирается до вызова formatter-а, поэтому utility не импортирует InvoiceStatus. Скопируйте одну команду в терминал: она печатает локализованную сумму и подпись статуса.

\n
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-а не обязана узнавать об этом состоянии. Это демонстрация границы, а не утверждение о конкретном репозитории.

\n

Как описать public API

\n

Начните со списка обещаний, а не с glob-паттерна. Для условного пакета запись может выглядеть так:

\n
const boundary={root:'@example/platform-formatting',publicNames:['formatMoney','formatDate'],forbiddenConsumerRoutes:['@example/platform-formatting/internal/*'],forbiddenUtilityTargets:['@example/billing-domain/*'],owner:'formatting-team',reviewBy:'2026-09-30'};
\n

root отвечает на вопрос, откуда импортировать. publicNames отделяет API от случайно доступного файла. forbiddenConsumerRoutes и forbiddenUtilityTargets показывают два разных направления запрета. Owner принимает изменения surface, а reviewBy не даёт временному adapter-у стать постоянной лазейкой.

\n

В настоящем проекте эту запись нужно связать с конкретным package entry point и тестом. Не называйте public-ом весь каталог только потому, что один consumer уже нашёл в нём helper. Публикуйте операцию с понятными входами, выходом, lifetime и правилами совместимости.

\n

Что именно проверяют Node, TypeScript и ESLint

\n

Поле exports в package.json задаёт доступные entry points и subpaths при обычном разрешении package specifier. Если ./internal/cache не перечислен, импорт через имя пакета должен быть отклонён Node как неэкспортированный subpath. Это ограничивает package surface, но не отвечает за доменную архитектуру. Локальный абсолютный путь, особый loader или generated code требуют отдельной проверки.

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

TypeScript в режимах node16, nodenext и поддерживаемом проектом bundler сопоставляет разрешение модулей с package maps. Это уменьшает расхождение между type-check и запуском, если compiler и runtime настроены согласованно. Сам компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility.

\n

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

\n

Порядок диагностики и исправления

\n
  1. Зафиксируйте симптом. Запишите точный module specifier, imported name и файл-источник из diff или заявки. Не заменяйте их формулой «пакеты сильно связаны».
  2. Назовите владельца смысла. Для каждого типа спросите, кто меняет его значения и правила отображения. Если ответ — billing, не переносите enum в formatting.
  3. Разделите направления. Отметьте, где utility зависит от domain, а где consumer зависит от internal. Это две записи и два решения, даже если они находятся в одном diff.
  4. Сверьте public record. Проверьте root specifier, разрешённое имя, запретный subpath, версии runtime и способ разрешения модулей.
  5. Выберите узкое действие. Верните mapping владельцу домена, добавьте reviewed root export, создайте named adapter или отклоните deep import. Не экспортируйте cache только ради совместимости.
  6. Проверьте отрицательный путь. Убедитесь, что неизвестное имя, internal subpath и новый доменный import отклоняются выбранным guard-ом. Отдельно проверьте type-only imports, re-exports, dynamic imports и generated code, если они есть.
  7. Повторите проверку. Запустите type check и lint в поддерживаемой конфигурации, проверьте consumer через root API и сравните public surface до и после изменения.
\n

Cache, adapter и постепенная миграция

\n

Если cache нужен только formatter-у, consumer должен вызывать публичную функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите стабильную операцию в reviewed export и опишите входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.

\n

Adapter допустим, когда у него есть владелец и срок жизни. Старый consumer может временно вызывать formatMoneyAdapter, пока команда переходит на root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а условие удаления — измеримым: например, поиск запрещённого specifier больше не находит потребителей.

\n

Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у и передайте formatter-у primitive или display data. Если consumer использует internal path, найдите требуемую операцию. При отсутствии стабильной семантики удалите зависимость и оставьте cache деталью владельца.

\n

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

\n

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

\n

exports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript может разрешить типы в одной конфигурации, а runtime — разрешить их иначе. Static rule не доказывает отсутствие dynamic загрузки. Поэтому результат нужно формулировать узко: «названный static import из заданного scope запрещён», а не «в репозитории больше нет domain leak».

\n

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

\n

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

\n

Границу можно принять, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Consumer использует root API. Type check и static guard проходят в поддерживаемой toolchain. Для временного adapter-а записаны owner, срок удаления и сигнал, по которому его можно удалить.

\n

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

\n

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

" }