Files
dev-conventions/conventions/lang/go/db-identifiers.md
T
av 0842850fae конвенции отделены от обвязки
- сами конвенции переехали в conventions/, описательное — в корень:
  LANGUAGE.md (язык записи) и GUIDE.md (как ведут конвенции)
- conv синхронизирует только conventions/, пути в origin даются
  относительно неё — раскладка копий в репозиториях не меняется
2026-07-25 19:23:32 +03:00

9.4 KiB

extends
extends
arch/db-identifiers.md

Идентификаторы: реализация на Go

Как arch/db-identifiers.md выглядит в Go-приложении, выбравшем ULID (ветка arch/db-identifiers.md R1.1). Форма записи — LANGUAGE.md.

Единая точка из arch/db-identifiers.md R3 — пакет internal/ident: он порождает идентификаторы (NewID, NewIDAt) и он же их разбирает (Parse). Правила ниже говорят, из каких мест кода эти функции зовутся.

Правила

R1. Генерация и разбор — только через internal/ident

ДОЛЖЕН. Идентификаторы порождаются и разбираются функциями пакета internal/ident; других генераторов и парсеров id в коде нет.

Почему. Реализация arch/db-identifiers.md R3 и R4. Вызов ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не выглядит нарушением: значение получается валидное, просто мимо нормализации регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт библиотеки где-либо, кроме internal/ident, находится поиском по имени модуля, а «забытая нормализация» не находится ничем, пока запрос молча не перестанет находить существующую запись.

R2. Первичный ключ генерируется в Create-методах store

ДОЛЖЕН. Новая сущность получает значение PK вызовом ident.NewID() внутри Create-метода слоя store.

Почему. arch/db-identifiers.md R2 требует, чтобы значение было известно до вставки, но не говорит, кто его присваивает. Store — последний слой, через который проходят все пути создания строки, включая импорт, фоновые задания и тесты. Генерация выше по стеку делает присвоение обязанностью каждого нового вызывающего, и первый забывший запишет пустую строку в колонку ключа: для строкового PK это валидное значение, база его не отклонит, и дефект обнаружится на второй такой вставке.

R3. Прочие идентификаторы генерируются в точке начала операции

ДОЛЖЕН. Идентификатор батча, задания или корреляционный ключ создаётся вызовом ident.NewID() там, где операция начинается.

Почему. Смысл такого идентификатора (arch/db-identifiers.md R7) — сшивать записи лога всей операции. Созданный ниже по стеку или в момент первой записи в базу, он не покрывает начальные шаги — а именно они нужны, когда операция упала до того, как что-либо записала: без общего ключа эти записи из лога не собираются вообще.

R4. Бэкфилл в миграциях — ident.NewIDAt(t)

ДОЛЖЕН. Идентификаторы, проставляемые существующим строкам в Go-миграции, порождаются с историческим временем строки, а не с текущим.

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

R5. Разбор — на входных границах, до обращения к store

ДОЛЖЕН. ident.Parse() вызывается в обработчике HTTP-роута, формы или callback'а бота — раньше, чем идентификатор попадёт в store.

Почему. Реализация arch/db-identifiers.md R5. Граница выбрана транспортная, потому что только на ней известен источник значения, от которого зависит реакция (R8): store видит одинаковую строку независимо от того, пришла она из URL или из собственной формы, и ответить по-разному оттуда уже невозможно.

R6. Id в структурах — обычный string

СЛЕДУЕТ. Поля идентификаторов в доменных и store-структурах имеют тип string.

Почему. Отдельный тип окупается только тогда, когда компилятор ловит им ошибку. От перепутывания двух идентификаторов одной семьи (userID и authorID) он не спасает — оба будут одного типа, и различают их имена параметров. Зато он требует конверсий на каждой границе с sql-драйвером, json и шаблонами, то есть даёт цену без выгоды.

R7. Отдельный тип — когда появляется вторая семья идентификаторов

ДОПУСКАЕТСЯ. Когда в коде оказываются два вида идентификаторов, которые можно перепутать, для них заводятся различимые типы.

Почему. Явное разрешение нужно, чтобы R6 не читался как запрет на типизацию навсегда. Условие названо ровно то, при котором тип начинает работать: пока все идентификаторы — string, подстановка одного вида вместо другого компилируется и обнаруживается только на данных.

R8. Реакция на невалидный id зависит от источника

ДОЛЖЕН. Когда разбор не удался, ответ определяется тем, откуда пришло значение:

Источник Ответ
R8.1 путь или query URL 404 без обращения к store
R8.2 собственная форма, callback-данные кнопки 400 либо понятное сообщение («кнопка устарела»)

Почему. Реализация arch/db-identifiers.md R5.1 и R5.2 в терминах HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи — и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2 значение сформировало само приложение, и невалидность означает баг интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно, в логах не оставляет аномалии и тем самым съедает единственный момент, когда дефект заметен.

R9. Транспорт не создаёт доменные ошибки

НЕ ДОЛЖЕН. Обработчик не конструирует доменный sentinel (например ErrNotFound), чтобы тут же сопоставить его со своим ответом.

Почему. Инверсия правила «трансляция у источника» из lang/go/errors.md. Sentinel — сообщение от слоя, который знает факт: строка не найдена, потому что store её искал. Сфабрикованный транспортом, он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли вообще поход в хранилище — а на этом держится вся диагностика по ошибкам.