От PRD к схеме базы данных за 30 минут: доменное моделирование с AI

Что такое проектирование схемы БД с помощью AI?

Проектирование схемы базы данных с помощью AI — это практика использования языковых моделей для ускоренного превращения документов с требованиями (PRD) в production-ready PostgreSQL-схемы. Это важно потому, что 70% ошибок в архитектуре БД закладываются в первые два часа работы, а их исправление после запуска обходится в 10–50 раз дороже, чем на этапе моделирования. В структурированном workflow AI сжимает цикл «гипотеза — валидация — исправление» с часов до минут: генерирует список сущностей, граф связей и SQL-миграции прямо из текста требований.

TL;DR

  • -70% ошибок в архитектуре БД закладываются в первые два часа — исправление на этапе моделирования обходится в 10–50 раз дешевле, чем после запуска.
  • -Четырёхшаговый workflow с AI (извлечение сущностей → построение связей → генерация SQL → валидация) превращает PRD в production-ready PostgreSQL-схему за 30 минут.
  • -AI стабильно пропускает поля аудита (created_at, updated_at, created_by) и RLS-политики — добавляйте явные инструкции для обоих в каждый промпт.
  • -Валидируйте схемы на трёх уровнях: формальная корректность SQL, покрытие User Stories (все сценарии выполнимы через ≤2 JOIN), анализ кардинальности под 12-месячную нагрузку.
  • -В SaaS с 500 организациями таблицы задач и комментариев достигают миллионов строк за год — составные индексы на (project_id, status) и (task_id, created_at) нужны с первого дня.

70% ошибок в архитектуре базы данных закладываются в первые два часа проектирования. Пропущенная связь many-to-many, неправильная нормализация, отсутствие индексов на частых запросах. Исправление этих ошибок после запуска стоит в 10-50 раз дороже, чем на этапе моделирования.

AI меняет экономику этого процесса. Не потому что генерирует идеальные схемы автоматически, а потому что сжимает цикл «гипотеза — валидация — исправление» с часов до минут. PRD на входе, проверенная PostgreSQL-схема на выходе. 30 минут вместо двух дней.

Дальше — четыре шага, которые превращают текстовый документ с требованиями в production-ready миграцию: извлечение сущностей, построение связей, генерация SQL, валидация.

Шаг 1: извлечение сущностей из PRD

PRD описывает продукт языком бизнеса. База данных работает с сущностями, атрибутами и связями. Задача первого шага — пересечь эту границу.

Типичный PRD содержит разделы: User Stories, Functional Requirements, Non-Functional Requirements. Сущности скрываются в существительных User Stories и описаниях функциональности.

Промпт для извлечения сущностей

Ты — senior database architect. Проанализируй PRD и извлеки все доменные сущности.

Для каждой сущности определи:
1. Имя (snake_case, единственное число)
2. Бизнес-описание (одно предложение)
3. Ключевые атрибуты с типами данных
4. Является ли сущность основной (core) или вспомогательной (lookup/junction)

PRD:
"""
{вставить текст PRD}
"""

Формат ответа — таблица. Не добавляй сущности, которые явно не следуют из требований.

Пример: PRD трекера задач

Допустим, PRD описывает систему управления проектами. Раздел User Stories содержит:

Как менеджер проекта, я хочу создавать проекты, добавлять участников и назначать задачи. Как участник, я хочу видеть назначенные мне задачи, оставлять комментарии и прикреплять файлы. Как администратор, я хочу управлять ролями и правами доступа в рамках организации.

AI извлекает сущности:

СущностьТипОписание
organizationcoreОрганизация-владелец проектов
usercoreПользователь системы
projectcoreПроект внутри организации
taskcoreЗадача внутри проекта
commentcoreКомментарий к задаче
attachmentcoreПрикреплённый файл
rolelookupРоль пользователя (admin, manager, member)
organization_memberjunctionСвязь пользователя с организацией и ролью
project_memberjunctionСвязь пользователя с проектом

Девять сущностей из трёх User Stories. Обратите внимание: AI выделил junction-таблицы organization_member и project_member, которые в PRD не упоминаются явно, но следуют из фразы «добавлять участников».

Что проверять на этом этапе

Три типичные ошибки AI на этапе извлечения:

Переизбыток сущностей. AI создаёт отдельные таблицы для каждого упомянутого понятия. «Приоритет задачи» становится таблицей priority, хотя достаточно enum-поля. Правило: если у понятия нет собственных атрибутов и оно не растёт со временем, это поле, а не таблица.

Пропуск аудита. PRD редко содержит требования вроде «записывать, кто и когда изменил задачу». Но в продакшене created_at, updated_at, created_by нужны почти всегда. Добавьте в промпт явное указание: «Включи стандартные поля аудита для каждой сущности».

Смешение доменной и инфраструктурной модели. Таблицы session, audit_log, notification_queue — это инфраструктура. На этапе domain modeling они отвлекают. Фокус на бизнес-сущностях.

Шаг 2: построение связей между сущностями

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

Промпт для построения связей

На основе списка сущностей определи все связи между ними.

Для каждой связи укажи:
1. Тип: one-to-one, one-to-many, many-to-many
2. Обязательность (nullable или NOT NULL)
3. Правило каскадного удаления (CASCADE, SET NULL, RESTRICT)
4. Направление владения (какая сторона "владеет" связью)

Сущности:
"""
{список сущностей из шага 1}
"""

Формат: таблица связей. Для many-to-many укажи junction-таблицу.

Результат: граф связей

organization  1──N  project            (CASCADE)
organization  1──N  organization_member (CASCADE)
user          1──N  organization_member (CASCADE)
role          1──N  organization_member (RESTRICT)
project       1──N  project_member     (CASCADE)
user          1──N  project_member     (CASCADE)
project       1──N  task               (CASCADE)
task          N──1  user               (SET NULL, nullable — assignee)
task          N──1  user               (RESTRICT — creator)
task          1──N  comment            (CASCADE)
user          1──N  comment            (RESTRICT)
task          1──N  attachment         (CASCADE)
user          1──N  attachment         (RESTRICT)

Здесь видно принципиальные решения. Удаление организации каскадно удаляет проекты и участников. Удаление пользователя, назначенного на задачу, обнуляет assignee_id (SET NULL), но не удаляет задачу. Удаление роли запрещено, если есть участники с этой ролью (RESTRICT).

Ловушка: скрытые many-to-many

PRD говорит: «пользователь назначается на задачу». Звучит как one-to-many, у задачи один assignee. Но через два спринта появляется требование «несколько исполнителей на одной задаче». One-to-many превращается в many-to-many, требуется junction-таблица, миграция данных, переписывание запросов.

Правило: если бизнес-логика допускает расширение «один → несколько», заложите many-to-many сразу. Стоимость пустой junction-таблицы близка к нулю. Стоимость миграции на продакшене — дни работы.

Задайте AI прямой вопрос:

Для каждой связи one-to-many оцени вероятность того,
что она станет many-to-many в ближайшие 6 месяцев.
Обоснуй на основе типичной эволюции подобных продуктов.

Шаг 3: генерация PostgreSQL-схемы

Сущности определены, связи описаны. Следующий шаг — SQL-миграция. Здесь AI экономит больше всего времени: правильные типы данных, индексы, constraints, комментарии.

Промпт для генерации SQL

Сгенерируй PostgreSQL-миграцию на основе списка сущностей и связей.

Требования:
- UUID v4 для первичных ключей (gen_random_uuid())
- created_at/updated_at TIMESTAMPTZ на каждой таблице
- Индексы на все внешние ключи
- Составные уникальные ограничения где логически необходимо
- CHECK constraints для enum-подобных полей
- Комментарии COMMENT ON TABLE/COLUMN для документации

Сущности и связи:
"""
{результат шагов 1 и 2}
"""

Формат: один SQL-файл, таблицы в порядке зависимостей (referenced перед referencing).

Результат: миграция

-- Migration: 001_initial_schema.sql
-- Generated from PRD: Project Management System

CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- ============================================================
-- Lookup tables
-- ============================================================

CREATE TABLE role (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(50) NOT NULL UNIQUE,
    description TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE role IS 'Роли пользователей в организации';

INSERT INTO role (name, description) VALUES
    ('admin',   'Полный доступ к организации'),
    ('manager', 'Управление проектами и участниками'),
    ('member',  'Базовый доступ к назначенным проектам');

-- ============================================================
-- Core tables
-- ============================================================

CREATE TABLE organization (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(255) NOT NULL,
    slug        VARCHAR(100) NOT NULL UNIQUE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE organization IS 'Организация — верхний уровень иерархии';
CREATE INDEX idx_organization_slug ON organization (slug);

CREATE TABLE "user" (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email       VARCHAR(320) NOT NULL UNIQUE,
    full_name   VARCHAR(255) NOT NULL,
    avatar_url  TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE "user" IS 'Пользователь системы';
CREATE INDEX idx_user_email ON "user" (email);

CREATE TABLE organization_member (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organization (id) ON DELETE CASCADE,
    user_id         UUID NOT NULL REFERENCES "user" (id) ON DELETE CASCADE,
    role_id         UUID NOT NULL REFERENCES role (id) ON DELETE RESTRICT,
    joined_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (organization_id, user_id)
);

COMMENT ON TABLE organization_member IS 'Членство пользователя в организации';
CREATE INDEX idx_org_member_org ON organization_member (organization_id);
CREATE INDEX idx_org_member_user ON organization_member (user_id);

CREATE TABLE project (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organization (id) ON DELETE CASCADE,
    name            VARCHAR(255) NOT NULL,
    description     TEXT,
    status          VARCHAR(20) NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'archived', 'completed')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE project IS 'Проект внутри организации';
CREATE INDEX idx_project_org ON project (organization_id);
CREATE INDEX idx_project_status ON project (status);

CREATE TABLE project_member (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    project_id  UUID NOT NULL REFERENCES project (id) ON DELETE CASCADE,
    user_id     UUID NOT NULL REFERENCES "user" (id) ON DELETE CASCADE,
    added_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (project_id, user_id)
);

COMMENT ON TABLE project_member IS 'Участие пользователя в проекте';
CREATE INDEX idx_proj_member_project ON project_member (project_id);
CREATE INDEX idx_proj_member_user ON project_member (user_id);

CREATE TABLE task (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    project_id  UUID NOT NULL REFERENCES project (id) ON DELETE CASCADE,
    created_by  UUID NOT NULL REFERENCES "user" (id) ON DELETE RESTRICT,
    assignee_id UUID REFERENCES "user" (id) ON DELETE SET NULL,
    title       VARCHAR(500) NOT NULL,
    description TEXT,
    status      VARCHAR(20) NOT NULL DEFAULT 'todo'
                CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
    priority    SMALLINT NOT NULL DEFAULT 0
                CHECK (priority BETWEEN 0 AND 3),
    due_date    DATE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE task IS 'Задача внутри проекта';
COMMENT ON COLUMN task.priority IS '0=none, 1=low, 2=medium, 3=high';
CREATE INDEX idx_task_project ON task (project_id);
CREATE INDEX idx_task_assignee ON task (assignee_id);
CREATE INDEX idx_task_status ON task (status);
CREATE INDEX idx_task_created_by ON task (created_by);

CREATE TABLE comment (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    task_id     UUID NOT NULL REFERENCES task (id) ON DELETE CASCADE,
    author_id   UUID NOT NULL REFERENCES "user" (id) ON DELETE RESTRICT,
    body        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE comment IS 'Комментарий к задаче';
CREATE INDEX idx_comment_task ON comment (task_id);
CREATE INDEX idx_comment_author ON comment (author_id);

CREATE TABLE attachment (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    task_id     UUID NOT NULL REFERENCES task (id) ON DELETE CASCADE,
    uploaded_by UUID NOT NULL REFERENCES "user" (id) ON DELETE RESTRICT,
    file_name   VARCHAR(500) NOT NULL,
    file_url    TEXT NOT NULL,
    file_size   BIGINT NOT NULL CHECK (file_size > 0),
    mime_type   VARCHAR(255) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE attachment IS 'Прикреплённый файл к задаче';
CREATE INDEX idx_attachment_task ON attachment (task_id);

-- ============================================================
-- Trigger: auto-update updated_at
-- ============================================================

CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_organization_updated BEFORE UPDATE ON organization
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_user_updated BEFORE UPDATE ON "user"
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_project_updated BEFORE UPDATE ON project
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_task_updated BEFORE UPDATE ON task
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_comment_updated BEFORE UPDATE ON comment
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_role_updated BEFORE UPDATE ON role
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();

Разбор ключевых решений

UUID вместо SERIAL. В распределённых системах и при работе с Supabase UUID предпочтительнее. Нет коллизий при merge данных из разных окружений, нет утечки информации о количестве записей.

CHECK constraints вместо отдельных таблиц. status и priority заданы CHECK-ами. Если список значений меняется раз в год, CHECK достаточно. Если значения управляются пользователем (кастомные статусы), нужна отдельная таблица.

Триггер updated_at. Один триггер на все таблицы. Без него updated_at требует явного обновления в каждом запросе, что ведёт к рассинхронизации.

Шаг 4: валидация PostgreSQL-схемы

Сгенерированная схема требует трёхуровневой проверки перед коммитом.

Уровень 1: формальная корректность

Запустите миграцию на чистой базе. Все ли таблицы создаются без ошибок? Все ли foreign keys ссылаются на существующие таблицы?

psql -d test_db -f 001_initial_schema.sql

Если миграция выполняется без ошибок, формальная корректность пройдена.

Уровень 2: покрытие требований

Для каждой User Story из PRD напишите SQL-запрос, который реализует её. Если запрос невозможен или требует JOIN через пять таблиц, схема неоптимальна.

Для каждой User Story из PRD напиши SQL-запрос,
который реализует основной сценарий.
Отметь запросы, которые требуют более двух JOIN
или не могут быть выполнены на текущей схеме.

Пример проверки для «менеджер видит все задачи проекта с именами исполнителей»:

SELECT
    t.id,
    t.title,
    t.status,
    t.priority,
    u.full_name AS assignee_name,
    t.due_date
FROM task t
LEFT JOIN "user" u ON u.id = t.assignee_id
WHERE t.project_id = :project_id
ORDER BY t.priority DESC, t.created_at;

Один JOIN, индекс на project_id уже создан. Запрос оптимален.

Уровень 3: нагрузочный анализ

Для каждой таблицы оцените ожидаемый объём данных через 12 месяцев. Это определяет, какие индексы критичны.

Оцени кардинальность каждой таблицы через 12 месяцев
для SaaS-продукта с 500 организациями,
средний размер организации — 20 пользователей.

На основе оценок проверь:
1. Достаточно ли текущих индексов для типичных запросов?
2. Нужны ли составные индексы?
3. Нужна ли партиционирование для больших таблиц?

Типичный результат для трекера:

ТаблицаСтрок через 12 мес.Индексы достаточны
organization500Да
user10 000Да
task500 000Добавить составной (project_id, status)
comment2 000 000Добавить составной (task_id, created_at)
attachment200 000Да

На основе этого анализа добавьте составные индексы:

CREATE INDEX idx_task_project_status ON task (project_id, status);
CREATE INDEX idx_comment_task_created ON comment (task_id, created_at);

Полный workflow: 30 минут от PRD до схемы

Разбивка по времени:

ШагВремяРезультат
Извлечение сущностей5 минСписок из 8-15 сущностей с типами
Построение связей5 минГраф связей с каскадными правилами
Генерация SQL10 минМиграция с индексами и constraints
Валидация10 минПроверенная схема с запросами

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

Типичные ошибки AI при моделировании схемы

Избыточная нормализация. AI создаёт таблицу address с полями country, city, street, zip. Для MVP часто достаточно JSONB-поля. Нормализуйте, когда появится потребность в запросах по отдельным полям адреса.

Отсутствие soft delete. Для сущностей, где удаление должно быть обратимым (задачи, комментарии, проекты), добавьте deleted_at TIMESTAMPTZ. AI не сделает этого без явной инструкции.

Игнорирование RLS. При работе с Supabase Row Level Security — не опция, а необходимость. Добавьте в промпт: «Сгенерируй RLS-политики для каждой таблицы на основе ролевой модели из PRD». Подробнее о защите данных на edge — в статье про circuit breaker в edge functions.

Отсутствие partial indexes. Для полей с низкой кардинальностью (status, is_active) обычные B-tree индексы неэффективны. Partial index WHERE status = 'active' занимает меньше места и работает быстрее для типичного запроса.

Расширение: от схемы к типобезопасному коду

Сгенерированная схема — не конечная точка. Следующий шаг — типизация на уровне приложения. Для TypeScript + Supabase:

npx supabase gen types typescript --local > src/types/database.ts

Эта команда превращает PostgreSQL-схему в TypeScript-типы. Каждая таблица становится интерфейсом, каждый столбец — полем с правильным типом. Изменение схемы автоматически обновляет типы, компилятор ловит несоответствия.

Для более сложных сценариев промпт-инженерии и управления контекстом при генерации кода полезен гайд по context engineering.

Чеклист: что проверить перед коммитом миграции

  1. Все таблицы создаются в порядке зависимостей (нет forward references)
  2. Каждый foreign key имеет индекс
  3. ON DELETE правила соответствуют бизнес-логике
  4. updated_at обновляется триггером, а не вручную
  5. Enum-подобные поля используют CHECK или отдельную таблицу (сознательный выбор)
  6. UUID вместо SERIAL для первичных ключей в распределённых системах
  7. Составные уникальные ограничения на junction-таблицах
  8. Комментарии на таблицах и неочевидных столбцах
  9. Partial indexes для полей с низкой кардинальностью
  10. RLS-политики (для Supabase)

Этот процесс масштабируется. PRD из 3 User Stories порождает 9 таблиц. PRD из 30 User Stories — 40-60 таблиц. Структура промптов и порядок шагов не меняются. Меняется только время на валидацию: чем больше сущностей, тем больше связей проверять. AI берёт на себя рутину генерации. Инженер фокусируется на решениях, которые определят жизнь системы на годы вперёд.


Нужна помощь с проектированием базы данных? Я помогаю стартапам внедрять AI-решения и строить продукты — belov.works.

FAQ

Можно ли использовать этот workflow с ORM (Prisma, SQLAlchemy) вместо сырого SQL?

Да. Пройдите четыре шага до получения валидированной SQL-миграции, а затем попросите модель преобразовать её в формат схемы вашего ORM. Начинать с SQL безопаснее, чем генерировать ORM-схему напрямую: SQL-шаг вынуждает явно принять решения по cascade rules и индексам, которые ORM обычно оставляет по умолчанию.

Как обрабатывать изменения схемы при эволюции PRD в процессе разработки?

Воспринимайте дифф PRD как новый входной документ. Передайте в промпт оригинальный список сущностей, текущую схему и изменённые требования, попросив модель определить, что нужно изменить. Генерируйте только дельта-миграцию, а не полную перезапись. После любого изменения схемы обязательно повторяйте второй уровень валидации (покрытие User Stories) — новые требования часто ломают запросы, которые раньше работали корректно.

Каков практический предел сложности схемы, при котором workflow перестаёт работать?

Workflow надёжно справляется с 40–60 сущностями в одном контекстном окне. Сверх этого модель начинает генерировать непоследовательные FK-ссылки и пропускать индексы. Решение: разбить домен на ограниченные контексты, прогнать четыре шага для каждого, а затем добавить финальный шаг для связей между контекстами.