Перейти к основному содержимому

ADR 0121 — Схема, разделённая по доменным агрегатам, за barrel

  • Статус: принят
  • Дата: 2026-08-29
  • Ссылается (без правки): ADR 0038 и ADR 0053 (границы агрегатов, которые отражает это разделение), ADR 0117 (тот же формат рассуждения: граница того стоит, когда общая вещь берёт цену, которую никто не выбирал платить).

Контекст​

apps/api/src/db/schema.ts насчитывал 2 452 строки с 51 объявлением pgTable и 34 объявлениями pgEnum — всё, от users до huggingface_model_pull_requests, от event log до chunks RAG.

Это уже был объявленный техдолг. В таблице долгов docs/architecture.md для него была строка с момента появления таблицы: «самый изменяемый файл репозитория … концентрирует таблицы в одном файле», с последствием, записанным в самой строке, — второй участник, одновременно меняющий схему, получает конфликт, гарантированно, потому что каждое изменение схемы попадает в один и тот же файл, к какой бы части продукта оно ни относилось. Число в этой строке (35 таблиц) уже отставало на 16 таблиц, что само по себе доказательство: файл рос быстрее, чем описывающий его абзац.

Три конкретные издержки, ни одной гипотетической:

  1. Каждый merge двух изменений схемы — текстовый конфликт, даже когда оба относятся к несвязанным агрегатам — колонка auth и ограничение backlog никак не связаны друг с другом и всё равно встречаются в одном hunk.
  2. git log --follow по схеме бесполезен. История файла — объединение истории каждой области продукта, поэтому «когда изменились chunks и почему» требует читать коммиты про refresh_tokens.
  3. Чтение требует знать, куда прокручивать. Файл уже был организован изнутри комментариями // --- section ---, называющими области продукта, — разделение не изобретает таксономию, а возводит ту, которую файл уже поддерживал вручную.

Решение​

1. Один файл на доменный агрегат, в apps/api/src/db/schema/​

Группы отражают apps/api/src/domain/*, границы агрегатов, к которым код уже привязан. Никакой новой таксономии: если у доменного слоя есть для этого папка, у схемы есть файл с этим именем.

файлтаблицыenums
iam.ts54
sessions.ts55
llm.ts76
actions.ts12
agents.ts41
instructions.ts20
backlog.ts33
architecture.ts20
git.ts35
psychologist.ts20
anamnese.ts40
auth.ts93
rag.ts11
containers.ts11
huggingface.ts11
backup.ts12
итого5134

Три позиции потребовали решения, а не справки, и каждая зафиксирована комментарием в начале файла, который её получил:

  • handoffs идёт в sessions.ts, а не agents.ts. Сущность — domain/sessions/handoff.entity.ts: handoff существует только внутри сессии, и доменный слой уже это говорил.
  • user_credentials идёт в llm.ts, а не iam.ts и не git.ts, хотя credential_provider покрывает ключи LLM и токены git. Сущность — domain/llm/user-credential.entity.ts, а у domain/git нет соответствия — разделение следует доменному слою, в том числе там, где выбор самого доменного слоя — компромисс.
  • backup.ts — единственный файл без соответствующей папки domain/. Backup — операционная инфраструктура без собственного бизнес-правила. Втиснуть его в architecture.ts или containers.ts значило бы заставить отображение лгать о том, что является агрегатом, чтобы избежать файла на 60 строк. Маленький честный файл стоит меньше, чем неверная граница.

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

Внешний ключ между файлами безопасен в цикле: .references(() => otherTable.column) принимает ленивый callback именно для того, чтобы ссылки вперёд разрешались. Enum между файлами — нет: storyPromotionModeEnum('story_promotion') выполняется при вычислении модуля, и цикл, чьё ребро указывает не в ту сторону, бросает ReferenceError: Cannot access '…' before initialization при импорте, а не при запросе.

Поэтому три enum остаются там, где их единственный вызывающий, а не там, куда их поставила бы группировка по теме:

  • project_execution_mode и story_promotion живут в iam.ts, а не в git.ts/backlog.ts. projects — единственный потребитель обоих. Поставить их в файл «темы» заставило бы iam.ts зависеть от git.ts и backlog.ts при вычислении, тогда как оба уже лениво зависят от iam.ts, — два цикла, а iam — первый модуль, который вычисляет barrel, то есть направление, которое падает.
  • failure_origin живёт в backlog.ts, общий для tasks.blocked_origin и delegations.failure_origin. agents.ts уже импортирует tasks из backlog.ts (для delegations.task_id), так что agents → backlog — уже существующее направление; обратное замкнуло бы петлю.

Получившийся граф импортов — DAG: iam.ts и backup.ts ничего не импортируют из соседей, а всё остальное течёт от них. Именно это свойство сохраняется при добавлении таблицы — а не алфавитный порядок.

3. schema.ts становится barrel; ни один потребитель не меняется​

apps/api/src/db/schema.ts теперь содержит 25 строк export * from './schema/<file>', в порядке чтения исходного файла (идентичность, сессия, LLM, действия, агенты и далее к краям), а не в алфавитном — порядок и есть оглавление схемы.

Каждый из 144 модулей, импортирующих из …/db/schema — 46 в src/, 98 в test/ и scripts/, — продолжает работать с нулём правок, потому что они по-прежнему импортируют из того же спецификатора, а barrel реэкспортирует всё. Это включает infrastructure/persistence/drizzle/drizzle-client.ts, чей import * as schema → drizzle(pool, { schema }) видит тот же набор экспортов, что и раньше.

4. drizzle.config.ts по-прежнему указывает на barrel​

schema: './src/db/schema.ts' не меняется. drizzle-kit разрешает модуль с помощью esbuild и следует цепочке export *, так что находит 51 таблицу через barrel — проверено, а не предположено: pnpm --filter api db:generate печатает полный перечень таблиц, а затем "No schema changes, nothing to migrate". Glob ('./src/db/schema/*.ts') был планом Б на случай, если бы это не сработало; не понадобился, и barrel — лучший указатель, потому что это также точка входа, которую использует код приложения, — одно место, решающее, что значит «схема», а не два, которые могут разойтись.

5. Критерий приёмки — миграция с нулевым diff, а не проходящий набор тестов​

Изменение чисто механическое: ни одна таблица, колонка, значение enum, имя индекса или выражение ограничения не изменились. Доказательство — db:generate не производит ни одного нового файла миграции и ни одного diff против snapshot в src/db/migrations/meta/, — проверка строже набора тестов, потому что Drizzle сравнивает выведенный SQL, так что перевёрнутый notNull, сменённое значение по умолчанию или переименованный индекс появились бы там даже при всех проходящих тестах.

Последствия​

  • Два человека могут менять схему одновременно без конфликта, при условии, что работают в разных агрегатах, — это обычный случай и ровно то, что строка долга называла невозможным.
  • git log по файлу схемы теперь читаем. git log --follow apps/api/src/db/schema/auth.ts отвечает «что произошло с таблицами auth» — вопрос, на который раньше не было ответа. Цена: история до этого коммита остаётся в schema.ts, а --follow не пересекает разделение (он следует переименованиям, а не разделениям один-ко-многим). Археология старше этого ADR идёт по старому пути.
  • Правилу schema-e-migrations в docs/.docmap.yml пришлось получить glob. Оно наблюдало литерал apps/api/src/db/schema.ts; после разделения новое CHECK-ограничение внутри schema/llm.ts даже не тронуло бы barrel, и правило осталось бы слепым, молча, именно к тому, ради наблюдения за чем оно существует, — к ограничениям, которые его собственное примечание называет бизнес-правилами в целом. apps/api/src/db/schema/** теперь наблюдается рядом с barrel, который остаётся реальным файлом, меняющимся всякий раз, когда таблица рождается или умирает.
  • Новая таблица теперь стоит двух правок, а не одной: таблица в файле её агрегата и — только если файл новый — строка в barrel. Добавление таблицы в существующий файл стоит столько же, сколько раньше.
  • Ацикличный граф импортов теперь инвариант, который никто не гарантирует. Нет теста, который бы его утверждал; будущий enum, поставленный не в тот файл, упадёт шумно при импорте (ReferenceError при загрузке, а не тонкий дефект), но упадёт в чьём-то терминале, а не на ревью. Объявлено, не решено.
  • Миграции не тронуты. src/db/migrations/ и его snapshot идентичны байт в байт; этот ADR закрывает половину «единственный файл» строки долга и ничего больше. Долг контракта схемы api↔engine — другая строка, по-прежнему открытая, и остаётся открытой.

Отвергнутые альтернативы​

  • Делить по слою, а не по агрегату (все enums в одном файле, все таблицы в другом, все индексы в третьем): даёт файлы, которые никому незачем открывать, и ставит budgets_scope_check в двух файлах от budgets. Также не решило бы проблему конфликтов, так как каждое изменение всё равно трогало бы файл enums.
  • Общий columns.ts, реэкспортирующий конструкторы колонок drizzle, чтобы каждый файл импортировал одну вещь. Отвергнуто: добавляет модуль, который существует лишь ради экономии набора, скрывает, какие примитивы файл действительно использует, и делает список импортов больше не резюме того, что делает файл.
  • Оставить schema.ts настоящим файлом и добавить разделение рядом (постепенно выводя из употребления): два источника истины для одних и тех же таблиц, и drizzle-kit увидел бы дубликаты. Отвергнуто сразу.
  • Перевести 144 потребителя на импорт из конкретного файла агрегата. Соблазнительно — это сделало бы явной зависимость каждого модуля от схемы, — но превращает механическое изменение с нулевым diff в изменение 144 файлов, которое нужно ревьюить построчно, и его можно сделать позже, файл за файлом, всякий раз, когда кто-то трогает один из них. Barrel этому не мешает; просто не требует этого сегодня.