конвенции отделены от обвязки

- сами конвенции переехали в conventions/, описательное — в корень:
  LANGUAGE.md (язык записи) и GUIDE.md (как ведут конвенции)
- conv синхронизирует только conventions/, пути в origin даются
  относительно неё — раскладка копий в репозиториях не меняется
This commit is contained in:
av
2026-07-25 19:23:32 +03:00
parent 62c5645dd4
commit 0842850fae
16 changed files with 42 additions and 23 deletions
+130
View File
@@ -0,0 +1,130 @@
---
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 её искал. Сфабрикованный транспортом,
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
вообще поход в хранилище — а на этом держится вся диагностика по ошибкам.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->