Моделирование данных: сущности, связи, ERD, словарь данных
Модель данных — самый долгоживущий артефакт проекта. Интерфейс перерисуют три раза, фреймворк
поменяют, процесс переделают под новую оргструктуру, а таблица orders с её кривым полем
status varchar(20) переживёт всех. Причина простая: код меняют деплоем, а данные — миграцией,
и чем их больше, чем больше систем на них смотрит, тем дороже каждое изменение. Ошибка в
кнопке стоит час. Ошибка в модели данных стоит квартал, потому что придётся не только
переписать схему, но и починить историю, отчёты, интеграции и то, что уже успели наговорить
клиентам на основе неправильных цифр.
Поэтому моделирование данных — не «работа для DBA, аналитик тут не нужен». Ровно наоборот: почти все вопросы модели данных — это вопросы к бизнесу, а не к СУБД. «Может ли у заказа быть два плательщика?», «уникален ли email?», «нужно ли знать, какая цена была в момент покупки?», «что значит удалить клиента?» — на эти вопросы не ответит ни один разработчик, зато на них можно получить три взаимоисключающих ответа от трёх стейкхолдеров. Вытащить эти ответы и зафиксировать их — работа аналитика.
Эта статья — о том, как превратить сырые формулировки в модель, которая находит противоречия; как построить ERD, который читают, а не пролистывают; и как вести словарь данных так, чтобы он жил дольше двух недель. Про то, откуда берутся сами формулировки, — выявление требований; про раскладку по слоям — виды требований; про оформление — документирование. Модель процесса из BPMN — прямой вход в эту статью: каждый шаг процесса создаёт, читает или меняет данные, и именно там видно, какие сущности вы забыли.
1. Три уровня модели: не путать разговоры
Главная организационная ошибка — обсуждать всё сразу. Заказчик говорит про смысл слова
«клиент», разработчик про varchar(50), и оба уверены, что говорят об одном. Разделение
на три уровня — это разделение аудиторий и решений.
| Уровень | Что содержит | Кто утверждает | Какое решение принимается |
|---|---|---|---|
| Концептуальный | 7–15 сущностей, связи, названия | заказчик, эксперты домена | «одинаково ли мы понимаем предметную область» |
| Логический | атрибуты, домены, обязательность, ключи, история | аналитик + разработка + QA | «какие факты храним и по каким правилам» |
| Физический | таблицы, типы, индексы, партиции | разработчик / DBA | «как это будет быстро работать» |
Практическое правило: аналитик владеет первыми двумя уровнями и ревьюит третий. Ревью физической модели — не про индексы, а про смысл: если в DDL появилось поле, которого нет в словаре, значит в системе живёт требование, о котором никто не договаривался. Обратный случай тоже показателен: поле есть в словаре, но в таблице отсутствует — требование потеряли.
Уровни не обязаны быть тремя документами. На двухнедельной доработке концептуальный уровень законно остаётся фотографией доски в тикете. Про то, когда формальность оправдана, — раздел 12.
2. Как найти сущности: от текста требований к кандидатам
2.1. Приём подчёркивания существительных
Старейшая техника (её описал Расселл Эбботт ещё в 1983 году для перехода от текста к программному дизайну) работает до сих пор: возьмите расшифровку интервью или описание процесса и подчеркните все существительные. Глаголы при этом — кандидаты в связи и операции.
Фрагмент интервью с руководителем клиентского сервиса:
«Клиент оформляет заявку на возврат в личном кабинете, выбирает товар из заказа, указывает причину, прикладывает фотографии. Мы даём 14 дней с момента доставки. Оператор проверяет заявку, потом склад осматривает товар, и если всё нормально, бухгалтерия возвращает деньги. Иногда возвращают только часть заказа. Нам нужен отчёт по причинам возврата в разрезе поставщиков.»
Из этого абзаца выпадает пятнадцать кандидатов. Половина из них — не сущности. Отсев делают пять вопросов.
2.2. Тест на сущность: пять вопросов
- Идентичность. Можно ли отличить два экземпляра друг от друга и по чему именно? Если «по чему» не находится — это не сущность, а значение.
- Множественность. Их действительно много и мы храним список? Если экземпляр всего один и он про конфигурацию системы — это параметр, а не сущность.
- Собственные атрибуты. Есть ли у неё хотя бы два-три факта, которые больше нигде не живут?
- Жизненный цикл. Меняется ли она во времени, есть ли состояния и переходы?
- Владение. Есть ли человек или система, которая её создаёт и правит? Если ответа нет, в проде будет пустая таблица.
или представление над данными"] UI -- "порог, срок, условие" --> RULE["Бизнес-правило: параметр
или таблица решений"] UI -- "вещь, о которой бизнес хранит факты" --> ID{"Можно отличить
два экземпляра? По чему?"} ID -- "нет" --> ATTR["Атрибут другой сущности"] ID -- "да" --> LIFE{"Есть свои атрибуты
и жизненный цикл?"} LIFE -- "только код и название" --> REF["Справочник
(классификатор)"] LIFE -- "да" --> DEP{"Может существовать
без родителя?"} DEP -- "нет, только внутри него" --> WEAK["Зависимая сущность:
позиция, вложение, строка"] DEP -- "да" --> STRONG{"Это факт о моменте
или долгоживущий объект?"} STRONG -- "произошло однажды" --> EVENT["Сущность-событие:
платёж, осмотр, списание"] STRONG -- "живёт и меняется" --> MASTER["Мастер-сущность:
клиент, товар, договор"]
2.3. Разбор кандидатов на кейсе возврата
| Кандидат | Вердикт | Почему и что за этим стоит |
|---|---|---|
| Заявка на возврат | сущность (мастер) | свой номер, статусы, автор, даты, живёт неделями |
| Позиция возврата | зависимая сущность | возвращают часть заказа → нужна строка на каждый возвращаемый товар |
| Причина возврата | справочник | нужен отчёт «в разрезе причин» → значит закрытый список, а не свободный текст |
| Статус заявки | атрибут + жизненный цикл | сам не сущность, но требует stateDiagram (раздел 6) |
| Фотографии | сущность-вложение | у файла свой срок хранения, размер, модерация → отдельная жизнь |
| Личный кабинет | не данные | канал взаимодействия; влияет на права, не на модель |
| 14 дней | бизнес-правило | параметр политики; «а для крупной бытовой техники тоже 14?» — новый вопрос |
| Оператор, склад, бухгалтерия | роли → Сотрудник + связь | роль это не сущность-человек; иначе один человек в двух ролях сломает модель |
| Деньги вернули | сущность-событие | у возврата денег есть сумма, дата, статус, идентификатор в шлюзе, возможен частичный |
| Отчёт по причинам | представление | запрос над данными; требование к аналитике, а не к схеме |
| Поставщик | сущность (мастер) | всплыла из требования к отчёту — типичная находка «сущность из отчёта» |
Последняя строка — важный приём: требования к отчётности вскрывают отсутствующие сущности и связи. Если бизнес хочет разрез по поставщику, а в модели поставщика нет или он не связан с товаром в момент продажи, отчёт невозможен, и это функциональное требование, а не задача BI.
3. Связи и мощности: главный источник неявных требований
Символы «вороньей лапы» (crow’s foot) — рабочий минимум. Их четыре, и за каждым стоит вопрос к живому человеку.
3.1. Шесть вопросов на каждую связь
Для каждой линии на схеме прогоняйте один и тот же набор. Он даёт больше найденных требований на час работы, чем любое интервью «расскажите, как у вас всё устроено».
| Вопрос | Что уточняет | Что находится на практике |
|---|---|---|
| «Может ли быть ноль?» | опциональность | гостевые заказы, черновики, «заведём клиента позже» |
| «Может ли быть больше одного?» | верхняя мощность | частичные возвраты, несколько плательщиков, два адреса |
| «Верхняя граница есть? 3 или 3000?» | объёмы, UI, НФТ | «до пяти фото» превращается в валидацию и лимит хранилища |
| «Может ли связь меняться со временем?» | историчность | перевод заказа на другого клиента, смена менеджера |
| «Кто и когда устанавливает связь?» | процесс и владение | выясняется, что никто → в проде поле пустое |
| «Есть ли у связи свои атрибуты?» | скрытая сущность | цена на момент, роль, период действия, комментарий |
Последний вопрос — самый продуктивный. Как только у связи появляется собственный факт, связь перестаёт быть связью: это сущность. «Товар в заказе» с ценой на момент покупки, «сотрудник в проекте» с ролью и периодом, «клиент и тариф» с датой подключения — все они ассоциативные сущности, и попытка обойтись «связью многие-ко-многим» гарантирует переделку.
3.2. Наивная модель: как выглядит запись со слов
Первое, что рисует неопытный аналитик по интервью выше:
В этой картинке пять неправд, и каждая — потерянное требование:
ORDER ||--|| RETURN_REQUESTутверждает, что у каждого заказа ровно один возврат и у каждого возврата ровно один заказ. Первое ложно (возвратов может не быть вовсе, а может быть два — разные товары в разное время), второе, скорее всего, верно, но это надо подтвердить.- Возврат связан с товаром, а не с позицией заказа. Значит потеряно: какая именно единица из двух купленных возвращается, по какой цене она была продана и с какой скидкой.
reason stringвместо справочника делает отчёт по причинам невозможным: в поле окажется «не подошёл размер», «размер», «мал», «Не подошло(((».photos string— список в одном поле, нарушение 1NF (раздел 8). Кто-то напишет через запятую, и через месяц появится задача «дайте фото по одному».- Нет ни возврата денег, ни осмотра, ни пункта приёма — три сущности-события просто выпали, хотя в процессе они есть.
3.3. Модель после вопросов
Обратите внимание на две детали, которые обычно и вскрывают неявные требования.
ORDER_ITEM ||--o{ RETURN_ITEM вместо ||--|| — прямое следствие фразы «иногда возвращают
только часть». А RETURN_REQUEST ||--o{ REFUND (не o|) — следствие вопроса «а если возврат
денег не прошёл, вы пробуете второй раз?». Ответ «конечно, и иногда двумя частями» добавляет
сущность с историей попыток, а вместе с ней — требование к идемпотентности, о котором
подробно в анализе API.
3.4. Разрешение «многие-ко-многим»
Связь M:N в концептуальной модели допустима, в логической — почти всегда нет. Разрешая её,
задайте три вопроса, и получите три разных ответа:
- Просто факт связи? → таблица-связка из двух ключей (товар и его теги).
- Есть свои атрибуты? → ассоциативная сущность с собственным ключом (позиция заказа).
- Связь действует в период? → сущность с интервалом валидности и запретом пересечений (цена товара по каналу продаж, раздел 7).
4. Идентичность и ключи — это бизнес-вопросы
4.1. Три вида ключей и зачем аналитику разница
| Вид | Пример | Кто решает | Ловушка |
|---|---|---|---|
| Технический (surrogate) | bigint return_id |
разработка | никогда не показывать в UI как «номер» |
| Бизнес-ключ (natural) | return_no = R-2026-000123 |
бизнес | требует формата, уникальности и правил генерации |
| Внешний идентификатор | psp_reference, ИНН, номер накладной |
внешняя система | не контролируется вами, может дублироваться и меняться |
Аналитику важно не «какой PK выбрать», а то, что бизнес-ключ — это отдельное требование с собственными критериями приёмки. Номер заявки диктуют по телефону — значит без похожих символов и с разумной длиной. Номер сквозной по годам или сбрасывается? Что происходит при переносе между юрлицами? Строго последовательный номер раскрывает конкурентам ваши объёмы — иногда это причина сделать его непоследовательным.
4.2. Классика жанра: «уникален ли email?»
Это самый надёжный способ найти противоречие между стейкхолдерами за одну встречу. Ответы:
- Маркетинг: конечно уникален, один человек — один аккаунт, иначе рассылка задваивается.
- B2B-продажи: нет, у отдела закупок один ящик
zakupki@, и заявки заводят четыре человека. - Поддержка: у семьи один ящик, мама и дочь заказывают отдельно, но пишут с одного адреса.
- Юристы: email — персональные данные, а согласие даёт конкретное лицо.
Все четверо правы, и ни один из ответов не является требованием. Требованием становится
решение, и оно почти всегда структурное: разделить сущности «Пользователь» (логин, уникален)
и «Контакт» (канал связи, не уникален), либо ввести уникальность в границах организации-tenant.
Плохой исход — уникальный индекс «потому что так чище», после которого поддержка учится
заводить ivan+2@mail.ru, и в отчётах два клиента вместо одного.
Формулировка, которую можно проверить, выглядит так:
Сценарий: один email на нескольких пользователей одной организации
Дано в организации ООО «Ромашка» есть пользователь с email zakupki@romashka.ru
Когда администратор организации приглашает второго пользователя с тем же email
Тогда приглашение принимается
И оба пользователя видят только свои заявки
И письма о статусе отправляются с указанием ФИО получателя в теме
4.3. Идентификаторы, которые нельзя брать за ключ
- Телефон. Меняется, переиспользуется оператором через полгода после отключения. Как логин — источник инцидентов доступа.
- Email. Меняется, у корпоративных сотрудников — при смене фамилии.
- ИНН/паспорт/СНИЛС. Меняются (смена паспорта — регулярно), у иностранцев отсутствуют, а ИНН у ИП и у физлица различаются по смыслу.
- Номер документа контрагента. Уникален у контрагента, но не у вас; два поставщика легко выдадут накладную № 1.
- Составной «естественный» ключ.
(дата, магазин, номер чека)кажется уникальным, пока не появится сторно и перенумерация.
Правило: бизнес-идентификатор храним и индексируем, но первичный ключ делаем техническим. Тогда изменение бизнес-правила стоит одну миграцию, а не переписывание всех FK.
5. Атрибуты и словарь данных
5.1. Словарь данных ≠ глоссарий
Эти два артефакта путают постоянно.
| Глоссарий | Словарь данных | |
|---|---|---|
| Единица | термин предметной области | атрибут конкретной сущности |
| Отвечает на | «что значит слово „активный клиент“» | «где лежит, какого типа, кто заполняет» |
| Аудитория | все, включая заказчика | разработка, QA, интеграции, BI, ИБ |
| Живёт | вики, начало ТЗ | рядом с моделью, в git |
Глоссарий — про единый язык (в DDD это ubiquitous language, см. стратегический дизайн). Словарь — про контракт на данные. Нужны оба, но словарь без глоссария превращается в перечень полей, а глоссарий без словаря — в красивые определения, которые никто не сверяет с базой.
5.2. Анатомия записи словаря
Минимальный набор полей, за каждым — предотвращённый инцидент:
| Поле записи | Зачем |
|---|---|
| Имя атрибута и сущность | адресация, поиск в коде |
| Бизнес-определение | 1–2 предложения на языке заказчика, без «поле для хранения» |
| Тип и домен | перечисление, диапазон, формат, единица измерения |
| Обязательность | и что значит пусто: нет значения / неизвестно / неприменимо |
| Источник (system of record) | откуда приходит истина |
| Кто заполняет | человек, роль или система; при ответе «никто» атрибут не нужен |
| Правила валидации | проверяемые, а не «должно быть корректным» |
| Пример значения | быстрее любого описания |
| Класс чувствительности | PII / платёжные / коммерческая тайна / публичные |
| Срок хранения и правило удаления | требование регуляторики, не «на всякий случай навсегда» |
| Использование в отчётности | предупреждает «мы поменяли смысл, а BI сломался» |
Фрагмент реального словаря для нашей заявки:
| Атрибут | Определение | Тип / домен | Обяз. | Источник | Валидация | Чувств. |
|---|---|---|---|---|---|---|
return_no |
Номер заявки, который клиент называет в поддержке | text, маска R-YYYY-NNNNNN |
да | генератор сервиса возвратов | уникален; без символов O, I, 0, 1 | публичный |
deadline_at |
Крайний момент, когда возврат ещё принимается | timestamptz |
да | вычисляется: доставка + политика | > created_at; в UTC, показ в TZ клиента |
публичный |
qty |
Возвращаемое количество единиц из позиции заказа | int, 1..99 |
да | клиент в кабинете | ≤ купленного минус уже возвращённое | публичный |
amount_minor |
Сумма к возврату в минорных единицах валюты | bigint, ≥ 0 |
да | расчёт по цене на момент покупки | ≤ суммы позиции; сверка с платежом | платёжные |
customer_comment |
Свободный комментарий клиента | text, ≤ 2000 |
нет | клиент | не используется в отчётах и правилах | PII (может содержать) |
reason_id |
Классифицированная причина возврата | FK на return_reason |
да | клиент выбирает из списка | значение активно на момент создания | публичный |
5.3. Словарь как код
Таблица в вики устаревает за спринт. Работающий вариант — YAML рядом с миграциями, который проверяется в CI и из которого генерируются документация и тесты.
# model/return_request.yaml — логическая модель, источник правды для документации
entity: return_request
title: Заявка на возврат
description: >
Обращение клиента на возврат одной или нескольких позиций заказа.
Живёт от создания до закрытия, порождает осмотры и возвраты денег.
owner: team-returns
system_of_record: returns-service
volume:
initial_rows: 0
growth_per_day: 4000 # оценка на пике распродажи
retention: 5y # требование бухгалтерии
identity:
primary: return_id
business_key: return_no
attributes:
- name: return_no
type: text
domain: "R-YYYY-NNNNNN"
required: true
filled_by: system
sensitivity: public
rules:
- уникален глобально
- не содержит O, I, 0, 1 — диктуется голосом
- name: state
type: enum
values: [draft, submitted, inspecting, approved, rejected, refunding, closed, cancelled]
required: true
filled_by: system
sensitivity: public
lifecycle: docs/return-state-machine.md
- name: customer_comment
type: text
max_length: 2000
required: false
filled_by: customer
sensitivity: pii
rules:
- не участвует в бизнес-правилах и отчётах
- маскируется в логах
relationships:
- to: order
cardinality: "many-to-one"
optional: false
- to: return_item
cardinality: "one-to-many"
min: 1
- to: refund
cardinality: "one-to-many"
min: 0
note: повторные попытки возврата денег допустимы
Простой линтер над такими файлами ловит на ревью то, что иначе всплывёт в проде.
"""Линтер логической модели: ищет пропуски, из-за которых потом переделывают схему."""
from __future__ import annotations
import sys
from pathlib import Path
import yaml # pip install pyyaml
CARDINALITIES = {"one-to-one", "one-to-many", "many-to-one", "many-to-many"}
SENSITIVITY = {"public", "internal", "pii", "payment", "secret"}
def load_model(directory: Path) -> dict[str, dict]:
"""Читает все YAML-файлы каталога в словарь «имя сущности → описание»."""
entities: dict[str, dict] = {}
for path in sorted(directory.glob("*.yaml")):
data = yaml.safe_load(path.read_text(encoding="utf-8"))
entities[data["entity"]] = data
return entities
def lint(entities: dict[str, dict]) -> list[str]:
"""Возвращает список замечаний. Пустой список — модель прошла ревью."""
problems: list[str] = []
for name, entity in entities.items():
if not entity.get("description"):
problems.append(f"{name}: нет описания на языке предметной области")
if not entity.get("identity", {}).get("primary"):
problems.append(f"{name}: не определена идентичность")
if not entity.get("system_of_record"):
problems.append(f"{name}: не указан источник истины — будет спор о дублях")
if not entity.get("volume", {}).get("retention"):
problems.append(f"{name}: не задан срок хранения — риск по 152-ФЗ и GDPR")
for attr in entity.get("attributes", []):
head = f"{name}.{attr['name']}"
if attr.get("required") is None:
problems.append(f"{head}: не сказано, обязателен ли атрибут")
if not attr.get("filled_by"):
problems.append(f"{head}: не указано, кто заполняет — поле останется пустым")
if attr.get("sensitivity") not in SENSITIVITY:
problems.append(f"{head}: класс чувствительности не задан или неизвестен")
if attr.get("type") == "enum" and not attr.get("values"):
problems.append(f"{head}: перечисление без списка значений")
# свободный текст без явного запрета всегда утаскивают в отчёты
if attr.get("type") == "text" and not attr.get("domain") and not attr.get("rules"):
problems.append(f"{head}: свободный текст без правил — кладбище требований")
for rel in entity.get("relationships", []):
target = rel.get("to")
if rel.get("cardinality") not in CARDINALITIES:
problems.append(f"{name} → {target}: мощность не задана или некорректна")
if target not in entities:
problems.append(f"{name} → {target}: связь на несуществующую сущность")
if rel.get("cardinality") == "many-to-many":
problems.append(
f"{name} → {target}: many-to-many не разрешена — "
"нужна ассоциативная сущность или период действия"
)
if rel.get("cardinality") in {"one-to-many", "many-to-one"} and "optional" not in rel \
and "min" not in rel:
problems.append(f"{name} → {target}: не сказано, допустим ли ноль")
return problems
if __name__ == "__main__":
found = lint(load_model(Path(sys.argv[1] if len(sys.argv) > 1 else "model")))
for line in found:
print("!", line)
sys.exit(1 if found else 0)
Сложность — O(E·A + R) по времени (проход по атрибутам и связям каждой сущности) и O(E + R)
по памяти: модель целиком держится в словаре. На реальных моделях в 200–400 сущностей это
десятки миллисекунд, поэтому линтер спокойно живёт в pre-commit.
5.4. Типы, на которых ломаются проекты
| Данные | Как надо | Почему |
|---|---|---|
| Деньги | целое в минорных единицах + код валюты (ISO 4217) | float даёт 0.1 + 0.2 ≠ 0.3 и расхождение с бухгалтерией |
| Момент времени | timestamptz, хранение в UTC, отображение в TZ пользователя |
«отчёт за сутки» без TZ — вечный спор с регионами |
| Дата без времени | date |
дата рождения и дата документа не имеют часового пояса |
| Период | два поля или диапазон, договоритесь о включении границ | [start, end) — самая безопасная договорённость |
| ФИО | одно-три поля с оговорками, отчество опционально | однокомпонентные имена, двойные фамилии, латиница |
| Адрес | структурировать только то, по чему ищете или считаете | полная структуризация международных адресов невозможна |
| Телефон | E.164, отдельно расширение | «+7 (999) 123-45-67» невозможно нормализовать задним числом |
| Страна, валюта, язык | ISO 3166-1 alpha-2, ISO 4217, BCP 47 | свои коды ломают все интеграции |
| Проценты и ставки | numeric с фиксированной точностью, договориться: 0.07 или 7 |
ошибка в 100 раз в расчёте комиссии |
| Признак удаления | явная семантика soft delete + правило доступа | иначе «удалённый» клиент вылезет в рассылке |
Отдельно — три смысла пустоты. NULL в поле «дата осмотра» может значить «осмотр ещё
не проводился», «данных нет, потому что миграция», «осмотр не требуется для этой категории».
Это три разных бизнес-факта, и если их не разделить, любой отчёт будет считать не то. Лечение:
либо отдельный признак/статус, либо явное значение-заглушка в справочнике, либо вынос
в отдельную сущность («Осмотр» просто не создаётся — и это самый честный вариант).
И ещё одно: поле comment — кладбище требований. Если в свободный комментарий начали
складывать смысл («если в комментарии написано „брак“, доставку оплачиваем мы»), у вас есть
неявное требование, которое надо превратить в атрибут или справочник. Признак болезни: кто-то
просит «поиск по комментариям» или «отчёт по комментариям».
6. Жизненный цикл: статус — это диаграмма, а не enum
Строчка state varchar(20) в модели — самое опасное место. За ней всегда стоит автомат,
и если он не нарисован, каждый разработчик придумает свой.
Диаграмма из десяти строк задаёт вопросы, которые иначе всплывут на приёмке:
- Кто имеет право на каждый переход? (это ролевая модель, а не «статусы»)
- Что можно делать в каждом состоянии: редактировать позиции, добавлять фото, отменять?
- Есть ли переходы назад? «Отклонена → на осмотре» после жалобы клиента — реальный кейс, и если его не предусмотреть, оператор будет создавать вторую заявку и ломать статистику.
- Что происходит по таймеру и кто его считает?
expired— не событие пользователя, значит нужен фоновый процесс, и это отдельное требование. - Какие состояния терминальны и что означает «закрыта» для отчётности?
6.1. Ортогональные оси: не смешивайте автоматы
Классический симптом гниющей модели — перечисление из семнадцати значений вида
approved_but_not_refunded, refunded_partially_awaiting_return_shipment. Это признак того,
что в одно поле сложили две-три независимые оси:
| Ось | Значения | Кто меняет |
|---|---|---|
| Состояние заявки | draft → … → closed | клиент и оператор |
| Состояние выплаты | not_needed / pending / sent / failed | платёжный шлюз |
| Состояние физического товара | ожидается / в ПВЗ / на складе / у клиента | логистика |
Три поля вместо одного дают ясные правила, независимые интеграции и понятные отчёты. Признак, по которому это ловится на ревью: невозможно ответить на вопрос «а что если деньги вернули, но товар до склада не доехал» одним значением поля.
Подробнее про state-диаграммы как инструмент — в UML для аналитика, а про то, как превратить переходы в проверяемые критерии, — в приёмке.
7. Время и история: самый недооценённый разрез
Три вопроса, которые надо задать по каждой значимой сущности. Пропуск любого из них — переделка через полгода.
- Нужна ли история значения? «Кто менял лимит клиента и когда» — либо есть, либо нет. Задним числом историю не восстановить.
- Нужна ли история связи? «Этот заказ раньше был на другом менеджере» — тот же вопрос, но про FK, и его почти всегда забывают.
- Нужен ли ответ „как было на дату“? Отчёт за прошлый квартал должен пересчитываться по правилам того квартала или по текущим? Это вопрос на 100 человеко-часов разницы.
7.1. Язык, на котором это обсуждают
| Подход | Что делает | Когда выбирают |
|---|---|---|
| Type 1 (перезапись) | храним только текущее значение | справочные атрибуты, история не нужна никому |
| Type 2 (версии строк) | новая версия с периодом действия | нужен ответ «как было на дату» |
| Type 4 (таблица истории) | текущее в основной, все изменения в отдельной | нужен аудит «кто и когда менял» |
| Снапшот в документе | копируем значение в момент события | цена, адрес, ставка НДС в чеке |
Терминология Type 1/2/4 пришла из хранилищ (Кимбалл), см. моделирование данных в data engineering — но она удобна и как язык разговора с бизнесом: «вам достаточно текущего значения или нужен ответ на дату?»
7.2. Снапшот против ссылки — правило, которое надо знать
Цена товара в позиции заказа не должна быть ссылкой на текущую цену SKU. Это не денормализация «ради скорости», а бизнес-факт: клиент купил по цене, действовавшей в момент покупки. То же с адресом доставки, ставкой налога, условиями тарифа, ФИО в договоре. Правило формулируется так: всё, что зафиксировано в юридически значимом документе, копируется значением, а не ссылкой.
Обратная ошибка тоже встречается: копируют то, что должно быть ссылкой (название категории в каждый товар), и потом переименование категории требует апдейта миллиона строк.
7.3. Периоды действия: как это выглядит в схеме
Цена по каналу продаж — классический случай «связь с периодом». В Postgres правило «периоды не пересекаются» выражается ограничением, а не кодом:
-- нужен btree_gist, чтобы сравнивать скалярные колонки внутри exclude-ограничения
create extension if not exists btree_gist;
create table sku_price (
sku_id bigint not null references sku (sku_id),
channel_id smallint not null references sales_channel (channel_id),
amount_minor bigint not null check (amount_minor >= 0),
currency char(3) not null, -- ISO 4217
valid_period tstzrange not null, -- [начало, конец) — граница исключается
-- бизнес-правило: у SKU в канале не может быть двух цен одновременно
constraint sku_price_no_overlap exclude using gist (
sku_id with =,
channel_id with =,
valid_period with &&
)
);
-- цена, действующая на момент оформления заказа
select amount_minor, currency
from sku_price
where sku_id = $1
and channel_id = $2
and valid_period @> $3::timestamptz;
Это тот случай, когда аналитик обязан понимать физический уровень: ограничение exclude
превращает бизнес-правило в невозможность его нарушить, а «мы проверим в коде» — в баг,
который найдут через год по расхождению выручки.
7.4. Битемпоральность: два времени вместо одного
Различайте время события (когда факт был верен в реальности) и время записи (когда мы об этом узнали). Пример: сотруднику задним числом подняли зарплату с 1 марта, а приказ провели 20 апреля. Вопрос «сколько он получал в марте» имеет два правильных ответа в зависимости от того, о каком времени вы спрашиваете, и оба нужны: первый — для расчёта, второй — для объяснения, почему в марте отчёт показывал другую цифру.
Признаки, что вам нужна битемпоральность: корректировки задним числом, регуляторная отчётность, финансовые пересчёты, страхование, медицина. Признак, что не нужна: никто никогда не спрашивает «почему в прошлом отчёте было иначе». Классификация темпоральных паттернов у Фаулера — Time Narrative — до сих пор лучшее короткое введение.
8. Нормализация — ровно столько, сколько нужно аналитику
Формальные определения нормальных форм аналитику не нужны, а три практических правила нужны.
- 1NF: одно значение в одном поле. Никаких «телефоны через запятую» и «список фото строкой». Симптом нарушения: кто-то просит «искать по одному из значений».
- 2NF/3NF: каждый факт в одном месте. Если в таблице заказа лежит название города по индексу, то однажды город переименуют, и у вас будет два города. Вопрос-детектор: «от чего зависит это поле — от всего ключа или от его части / от другого поля?»
- Денормализация — это требование, а не грех. Снапшот суммы в счёте, копия адреса
доставки, посчитанный
items_count— легальны, если названы как снапшоты и у них есть правило пересчёта (или явный запрет пересчёта). Нелегальны — если появились «чтобы быстрее» без записи в словаре.
Формальная сторона вопроса разобрана в реляционной модели, а последствия для запросов — в индексах и планах.
8.1. Три анти-паттерна, которые приносит бизнес
«Сделайте гибкие пользовательские поля» (EAV). Просьба звучит невинно, стоит дорого:
пропадают типы, валидация, отчёты и производительность. Правильная реакция — не «нет», а четыре
вопроса: кто заводит поля, как часто, кто по ним ищет и строит отчёты, нужны ли по ним правила.
В девяти случаях из десяти выясняется, что нужно 5–7 конкретных атрибутов (заведите их) или
одна дополнительная сущность. Если гибкость действительно нужна — jsonb с описанной схемой
и явным списком того, что не индексируется и не попадает в отчёты.
«Один справочник на всё» (таблица dictionary с полем type). Экономия десяти таблиц
покупается потерей FK-целостности и вечными вопросами «а какие значения тут допустимы».
«Поле „прочее“» в справочнике. Через год 40% значений — «прочее», а отчёт по причинам
бесполезен. Лечение: в справочнике есть other, но обязателен уточняющий текст, и на дашборде
висит доля other как метрика качества данных.
9. Владение данными: где живёт истина
Как только систем становится больше одной, главный вопрос модели — не «какие поля», а «кто хозяин». Инструмент — матрица CRUD (кто создаёт, читает, изменяет, удаляет).
| Сущность | Сайт | CRM | Биллинг | WMS (склад) | DWH |
|---|---|---|---|---|---|
| Клиент | C R U | CRUD (SoR) | R | — | R |
| Заказ | C R | R U | R | R | R |
| SKU | R | R | R | CRUD (SoR) | R |
| Цена | R | R | CRUD (SoR) | — | R |
| Заявка на возврат | C R U | R U | R | R U | R |
| Возврат денег | R | R | CRUD (SoR) | — | R |
Правила чтения матрицы: у каждой сущности ровно один владелец (SoR); два владельца —
гарантированные дубли и расхождения; ноль владельцев — сущность, которую никто не поддерживает.
Столбец с одними R — потребитель, ему нужен контракт на чтение, а не доступ к базе.
9.1. Как выглядит конфликт владения
Разбор этой диаграммы даёт четыре требования, которых не было ни в одном интервью: нормализация email перед сравнением, единый идентификатор клиента между системами, правило дедупликации с ответственным за разрешение конфликтов, и контракт на выгрузку. Про то, как это оформляется в интеграционные требования, — анализ API.
9.2. Омонимы: одно слово — разные сущности
Самая частая причина «мы же договорились, а получилось не то». Слово одно, сущности разные, и попытка сделать «одну таблицу клиентов на всё» приводит к таблице с сорока nullable-полями.
| «Клиент» в контексте | Что это на самом деле | Ключевые атрибуты |
|---|---|---|
| Маркетинг | лид, возможно анонимный | канал, кампания, cookie |
| Продажи (B2B) | организация | ИНН, договор, менеджер |
| Биллинг | плательщик | реквизиты, лимит, баланс |
| Склад | получатель | адрес, телефон, окно доставки |
| Поддержка | обратившийся | контакты, история тикетов |
Правильный ход — не унификация любой ценой, а признание разных контекстов с явным соответствием между ними (маппинг идентификаторов). Это и есть ограниченные контексты из DDD, см. bounded contexts и тактические строительные блоки. Обратная сторона — синонимы: «заявка», «обращение», «тикет» и «кейс» в четырёх отделах оказываются одной сущностью, и это надо ловить глоссарием.
10. Каталог того, что чаще всего ломается
| # | Симптом в модели или тексте | Чем опасно | Вопрос, который вскрывает | Как починить |
|---|---|---|---|---|
| 1 | Связь 1:1 между документами |
скрыт частичный/повторный случай | «а бывает два? а ноль?» | заменить на o{ и описать правило |
| 2 | Нет опциональности, все FK обязательны | процесс невозможно начать | «кто и когда заполнит это поле?» | явная опциональность на схеме + правило заполнения |
| 3 | «email уникален» без контекста | конфликт стейкхолдеров всплывёт на UAT | «а корпоративный ящик отдела?» | разделить Пользователя и Контакт |
| 4 | «Данные должны быть актуальными» | непроверяемое требование | «актуальными с какой задержкой и по чьим часам?» | «расхождение с SoR ≤ 5 минут в 99% случаев» |
| 5 | «Сделайте гибкие поля» | хотелка без задачи | «какое решение вы принимаете по этим полям?» | конкретные атрибуты или отдельная сущность |
| 6 | Статус из 15+ значений | смешаны независимые оси | «что если деньги вернули, а товар не приехал?» | несколько полей + stateDiagram |
| 7 | Свободный текст вместо справочника | отчёт невозможен | «этот разрез нужен в отчётности?» | справочник с is_active и кодами |
| 8 | Нет истории значения | восстановить нельзя | «кто-нибудь спросит „как было на дату“?» | Type 2/4 или журнал изменений |
| 9 | NULL с тремя смыслами |
все отчёты считают не то | «пусто — это „нет“, „неизвестно“ или „не нужно“?» | явные признаки или отдельная сущность |
| 10 | Один справочник на всё | потеря целостности и смысла | «какие значения тут вообще допустимы?» | отдельные справочники + FK |
| 11 | Enum, который бизнес хочет править | релиз ради нового значения | «кто и как часто добавляет значения?» | таблица-справочник + права |
| 12 | Нет правила удаления | конфликт GDPR и бухгалтерии | «удалить — это стереть или скрыть?» | анонимизация + сроки хранения по сущностям |
| 13 | Две системы создают одну сущность | дубли и расхождение отчётов | «кто источник истины?» | матрица CRUD, один SoR |
| 14 | Модель без объёмов | НФТ появятся после аварии | «сколько строк в день и за сколько лет?» | оценка объёмов (раздел 11) |
| 15 | Атрибут без владельца | пустое поле в проде | «кто его заполняет?» | убрать атрибут или назначить владельца |
| 16 | Одно слово, разные сущности | «сделали не то» | «в вашем отделе клиент — это кто?» | глоссарий + разделение контекстов |
10.1. Как превратить правило модели в проверяемое требование
Каждое ограничение модели должно доживать до критерия приёмки, иначе его не проверят. Шаблон перевода: ограничение → нарушающий сценарий → ожидаемое поведение → сообщение пользователю.
Сценарий: нельзя вернуть больше, чем куплено
Дано в заказе 2 единицы SKU-1001
И по нему уже одобрен возврат 1 единицы
Когда клиент создаёт заявку на возврат 2 единиц SKU-1001
Тогда заявка не создаётся
И клиенту показано "Доступно к возврату: 1 шт."
И в журнале зафиксирована попытка с причиной QTY_EXCEEDED
Сценарий: цена возврата берётся на момент покупки
Дано SKU-1001 куплен 1 марта по 1000 RUB
И 10 марта цена изменена на 1500 RUB
Когда 12 марта одобрен возврат этой единицы
Тогда сумма возврата равна 1000 RUB
Требования вида «данные должны быть корректными», «система должна поддерживать актуальность», «справочники должны быть гибкими» проверить нельзя — это не требования, а намерения. Механика превращения их в измеримое — в нефункциональных требованиях и приёмке; техники подбора сценариев (границы, классы эквивалентности) — в тест-дизайне.
11. Объёмы и чувствительность: мост к нефункциональным требованиям
Модель без чисел — половина модели. Таблица объёмов заполняется на 15 минут и определяет архитектуру.
| Сущность | Сейчас | Прирост/день | Хранить | Чувствительность | Следствие |
|---|---|---|---|---|---|
| Клиент | 4 млн | 3 тыс. | до отзыва согласия | PII | анонимизация, а не удаление |
| Заказ | 60 млн | 40 тыс. | 5 лет | внутренние | партиционирование по дате |
| Позиция заказа | 210 млн | 140 тыс. | 5 лет | внутренние | самая тяжёлая таблица, следить за индексами |
| Заявка на возврат | 900 тыс. | 4 тыс. (пик распродажи) | 5 лет | PII в комментариях | маскирование в логах |
| Фото к возврату | 2 млн | 12 тыс. | 1 год после закрытия | PII (случайно) | объектное хранилище, не БД |
| Возврат денег | 700 тыс. | 3 тыс. | 5 лет (бухгалтерия) | платёжные | аудит доступа, сверки |
Из такой таблицы напрямую вытекают требования: сроки хранения и удаления, шифрование, маскирование, ограничение доступа по ролям, время выполнения отчётов, окно миграции. Классификация данных и правовые сроки — тема приватности и комплаенса; GDPR формулирует минимизацию и ограничение хранения в статье 5, право на удаление — в статье 17; в российском контуре тот же смысл несёт 152-ФЗ «О персональных данных». Практический вывод для модели: удаление клиента почти никогда не означает удаление строк — заказы нужны бухгалтерии, поэтому в модели заранее предусматривают анонимизацию (обезличивание PII с сохранением фактов).
Отдельная строка работы — качество унаследованных данных. Прежде чем обещать заказчику новую модель, посчитайте по существующей базе: сколько строк не проходят будущие ограничения. Три запроса дают больше, чем неделя обсуждений.
-- сколько клиентов имеют дубль по нормализованному email
select count(*) from (
select lower(btrim(email)) as e, count(*) as c
from customer
where email is not null
group by 1 having count(*) > 1
) d;
-- сколько заказов сломает будущий обязательный FK на клиента
select count(*) from orders where customer_id is null;
-- какая доля причин возврата — свободный текст вне будущего справочника
select round(100.0 * count(*) filter (where reason not in (select title_ru from return_reason))
/ nullif(count(*), 0), 1) as pct_unmapped
from return_request_legacy;
Результат «12% заказов без клиента» превращает красивое ||--o{ в отдельную задачу
на очистку данных и в требование к миграции. Об инструментах контроля — в
качестве данных и governance.
12. Формальный документ или схема на доске
Моделирование данных — та область, где формальность оправдана чаще, чем в процессах: данные живут дольше и меняются дороже. Но не всегда.
| Ситуация | Достаточно | Не нужно |
|---|---|---|
| Добавляем одно поле в существующую сущность | строка в словаре + описание в тикете | новая версия ERD |
| Новая сущность внутри своего сервиса | ERD в PR + запись в словаре | согласование с архитектурным комитетом |
| Сущность, которую читают три системы | логическая модель + контракт + согласование владельцев | физическая модель в ТЗ |
| Персональные или платёжные данные | словарь с классификацией, сроками, правилами доступа | — |
| Миграция унаследованной базы | модель as-is и to-be + правила соответствия полей | — |
| Обсуждение «а что такое активный клиент» | доска, фото, две строки в глоссарии | документ на 20 страниц |
| Discovery, гипотеза на две недели | набросок из шести блоков | словарь данных |
Критерии, по которым решают: цена ошибки (деньги, регуляторика, персональные данные), число сторон (одна команда против пяти систем), срок жизни (спринт против пяти лет), наличие внешних читателей (интеграции, аудит, партнёры).
Рабочий ритуал, который стоит недорого и почти всегда оправдан:
- Доска или mermaid на 20 минут: сущности и связи, вслух проговорить мощности.
- Фото/диаграмма в тикет — это уже артефакт, его можно оспорить.
- Как только сущность попадает в бэклог: запись в словарь (обязательность, владелец, чувствительность, объёмы).
- ERD в git рядом с кодом, обновляется в том же PR, что и миграция.
- Раз в квартал: сверка словаря с реальным DDL (
SchemaSpyили запрос кinformation_schema— расхождения найдутся всегда).
Главный антипаттерн формальности — модель, которая обновляется отдельным процессом. Диаграмма в стороннем редакторе без экспорта в репозиторий устаревает за два спринта, после чего вредит: люди принимают решения по неверной картинке. Поэтому mermaid и текстовые форматы (dbml, PlantUML) выигрывают у красивых редакторов — они лежат в git и проходят ревью вместе с кодом.
13. Чек-лист ревью модели данных
Пройдитесь по нему перед тем, как отдать модель в разработку. Каждый пункт — это найденный на моей практике инцидент.
- Каждая сущность имеет определение на языке предметной области, а не «таблица для хранения».
- У каждой сущности определена идентичность и отдельно — бизнес-ключ, если он нужен людям.
- Ни один бизнес-идентификатор не используется как первичный ключ.
- У каждой связи заданы обе мощности и обе опциональности, и они кем-то подтверждены.
- Ни одной связи
many-to-manyне осталось на логическом уровне. - У каждой связи с атрибутами есть своя сущность.
- Каждый атрибут имеет тип, домен, обязательность и того, кто его заполняет.
- Для каждого nullable-атрибута написано, что означает пустота.
- Все перечисления либо закрыты и обоснованы, либо превращены в справочники с
is_active. - Деньги, время, телефоны, страны и валюты приведены к стандартам, а не «как получилось».
- Для каждой сущности отвечено: нужна ли история значений, история связей, ответ «на дату».
- Все снапшоты названы снапшотами, и у них есть правило (пересчитывается или нет).
- Статусы вынесены в state-диаграмму, и оси не смешаны в одном поле.
- У каждой сущности один источник истины; матрица CRUD заполнена по всем системам.
- Указаны объёмы, прирост, срок хранения и класс чувствительности.
- Для персональных данных описано, что происходит при отзыве согласия.
- Проверено, сколько существующих строк не проходят новые ограничения.
- Каждое ограничение модели имеет соответствующий сценарий приёмки.
- Все омонимы («клиент», «заявка») разведены по контекстам с маппингом.
- ERD и словарь лежат в репозитории и обновляются в том же PR, что миграция.
Мини-итог
- Модель данных живёт дольше кода и интерфейса, поэтому ошибка в ней самая дорогая в проекте.
- Разделяйте уровни: концептуальный — про слова и смысл, логический — про факты и правила, физический — про производительность. Аналитик владеет первыми двумя.
- Сущности находят подчёркиванием существительных и отсеивают пятью вопросами: идентичность, множественность, свои атрибуты, жизненный цикл, владелец.
- Мощности и опциональность — это бизнес-правила. Шесть вопросов на каждую связь дают больше требований, чем час свободного интервью.
- Связь с собственными атрибутами — это сущность.
1:1между документами почти всегда ложь. - Бизнес-ключ и первичный ключ — разные вещи; ни один внешний идентификатор не годится в PK.
- Словарь данных отвечает на «кто заполняет, что значит пусто, кто источник, сколько хранить» — и должен лежать в git, а не в вики.
- Статус — это автомат, а не enum; смешение ортогональных осей в одном поле гарантирует переделку отчётности.
- Историчность решается один раз и до релиза: задним числом историю не восстановить.
- Один источник истины на сущность; два владельца = дубли; матрица CRUD ловит это за полчаса.
- Формальность пропорциональна цене ошибки, числу читателей и сроку жизни данных. Доска и фото — законный артефакт для короткой гипотезы, словарь — обязателен для PII и интеграций.
Источники
- Peter Chen, The Entity-Relationship Model — Toward a Unified View of Data, ACM TODS, 1976 — первоисточник ER-модели.
- E. F. Codd, A Relational Model of Data for Large Shared Data Banks, 1970 — откуда вообще взялись отношения и нормализация.
- Graeme Simsion, Graham Witt, «Data Modeling Essentials» — самая практичная книга именно для аналитика; Len Silverston, «The Data Model Resource Book» — каталог готовых универсальных моделей (клиенты, продукты, заказы), экономит недели.
- Karl Wiegers, Joy Beatty, «Software Requirements» — материалы и шаблоны автора: processimpact.com.
- DAMA-DMBOK — свод знаний по управлению данными: владение, качество, метаданные, governance.
- BABOK v3, IIBA — раздел про data modelling и data dictionary в системе техник анализа.
- Martin Fowler, Time Narrative, Temporal Property, Ubiquitous Language, Analysis Patterns.
- Kimball Dimensional Modeling Techniques — откуда SCD Type 1/2/4.
- Документация PostgreSQL: типы данных, диапазоны и exclude-ограничения.
- Стандарты: RFC 3339 (даты и время), ISO 4217 (валюты), ISO 3166 (страны), JSON Schema (описание полуструктурированных данных).
- Falsehoods programmers believe about names и Your Calendrical Fallacy Is — списки допущений, которые ломают модели ФИО и даты.
- Инструменты: синтаксис ER-диаграмм в Mermaid, dbdiagram.io и формат DBML, PlantUML IE-диаграммы, SchemaSpy для реверса существующей базы, DataHub и OpenMetadata как каталоги метаданных промышленного масштаба.
- Требования к учебным проектам портала как пример короткой формы — требования.
Что дальше
Модель данных описала, что мы храним и по каким правилам. Дальше начинается самое конфликтное: эти данные надо отдать и получить от чужих систем, у которых свои представления о «клиенте», свои форматы дат и своё мнение о том, что делать при ошибке. Следующая статья — про контракты интеграций: как описать API так, чтобы обе стороны поняли одинаково, что происходит при таймауте, повторе и частичном отказе.
Анализ интеграций и API: контракты, форматы, сценарии обмена, ошибки