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

9.5 KiB

Идентификаторы сущностей

Как выбираются и как выглядят первичные ключи сущностей. Форма записи — LANGUAGE.md.

Область действия

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

Правила

R1. Вид первичного ключа выбирается один раз на репозиторий

ДОЛЖЕН. Репозиторий отвечает на один вопрос и держит ответ для всех своих таблиц:

Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по идентификатору из URL, запроса API или callback-данных?

Ответ Вид ключа
R1.1 да, хотя бы одна сортируемый строковый идентификатор, который генерирует приложение (ULID) — во всех таблицах, включая внутренние
R1.2 ни одной автоинкремент

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

Критерий — именно адресация: снаружи по этому идентификатору возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима.

Запрет смешивания касается двух видов сгенерированных суррогатных ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья категория, они допустимы при любом ответе.

R2. При выборе R1.1 идентификатор генерирует приложение, а не база

ДОЛЖЕН. Значение ключа известно до вставки строки.

Почему. Идентификатор нужен раньше, чем база ответит: его пишут в лог начатой операции, кладут в связанные записи одной транзакции и возвращают клиенту. Генерация на стороне базы вынуждает либо ждать last_insert_rowid и достраивать связи вторым проходом, либо иметь два источника истины о моменте создания.

R3. Генерация и разбор идентификаторов — в единственной точке

ДОЛЖЕН. Один модуль порождает идентификаторы, он же их разбирает. Самодельных генераторов и парсеров в коде нет.

Почему. Нормализация регистра (R4) и проверка формата обязаны применяться ко всем идентификаторам без исключения. Любая вторая точка входа рано или поздно окажется той, где нормализацию забыли, — и дефект проявится не там, где создан.

R4. Канонический вид — нижний регистр

ДОЛЖЕН. Идентификаторы порождаются и хранятся в нижнем регистре.

Почему. Сравнение строк в базе обычно побайтовое, поэтому регистр — не косметика, а корректность поиска. Спецификация ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3) разный регистр появится в базе сам собой.

R5. Внешний идентификатор разбирается до обращения к базе

ДОЛЖЕН. Значение, пришедшее снаружи, проходит разбор и нормализацию раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от источника:

Откуда пришёл Разбор не удался →
R5.1 путь или query URL «не найдено» без обращения к хранилищу
R5.2 собственная форма, данные кнопки «некорректный ввод» либо «элемент устарел»

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

Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию точно. Мусор из собственной формы — это баг интерфейса или устаревший экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики единственный момент, когда он заметен.

R6. У таблиц-деталей допустим естественный или составной ключ

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

Почему. Суррогат поверх естественного ключа создаёт второй способ адресовать ту же строку — а значит, возможность рассинхрона между ними и лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной информации он не несёт.

R7. Прочие генерируемые идентификаторы — через ту же точку

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

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

Почему ULID, а не UUID

Ветка R1.1 требует сортируемый строковый идентификатор. UUIDv4 не сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него остаются два довода: 36 символов против 26 и дефисы, из-за которых идентификатор не берётся ни двойным кликом, ни grep-ом как одно слово.

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

Связано

  • arch/time.md — метки времени тоже генерирует приложение, а не схема.