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 таблиц, что само по себе
доказательство: файл рос быстрее, чем описывающий его абзац.
Три конкретные издержки, ни одной гипотетической:
- Каждый merge двух изменений схемы — текстовый конфликт, даже когда оба относятся к несвязанным агрегатам — колонка auth и ограничение backlog никак не связаны друг с другом и всё равно встречаются в одном hunk.
git log --followпо схеме бесполезен. История файла — объединение истории каждой области продукта, поэтому «когда изменилисьchunksи почему» требует читать коммиты проrefresh_tokens.- Чтение требует знать, куда прокручивать. Файл уже был организован
изнутри комментариями
// --- section ---, называющими области продукта, — разделение не изобретает таксономию, а возводит ту, которую файл уже поддерживал вручную.
Решение
1. Один файл на доменный агрегат, в apps/api/src/db/schema/
Группы отражают apps/api/src/domain/*, границы агрегатов, к которым код
уже привязан. Никакой новой таксономии: если у доменного слоя есть для этого
папка, у схемы есть файл с этим именем.
| файл | таблицы | enums |
|---|---|---|
iam.ts | 5 | 4 |
sessions.ts | 5 | 5 |
llm.ts | 7 | 6 |
actions.ts | 1 | 2 |
agents.ts | 4 | 1 |
instructions.ts | 2 | 0 |
backlog.ts | 3 | 3 |
architecture.ts | 2 | 0 |
git.ts | 3 | 5 |
psychologist.ts | 2 | 0 |
anamnese.ts | 4 | 0 |
auth.ts | 9 | 3 |
rag.ts | 1 | 1 |
containers.ts | 1 | 1 |
huggingface.ts | 1 | 1 |
backup.ts | 1 | 2 |
| итого | 51 | 34 |
Три позиции потребовали решения, а не справки, и каждая зафиксирована комментарием в начале файла, который её получил:
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 этому не мешает; просто не требует этого сегодня.