Как спроектировать схему БД, чтобы потом не плакать
Самая дорогая ошибка в проекте — кривая схема БД. Код можно отрефакторить за вечер, фронт переделать за неделю. А переехать с «всё в одной таблице» на «нормализовано» — это миграция данных на живой системе с риском всё положить. Поэтому 30 минут размышлений над схемой ДО кода экономят неделю боли потом. В этом уроке — практический фреймворк проектирования.
Алгоритм: от пользовательских историй к таблицам
Не начинай со схемы — начни со списка действий пользователя. «Юзер регистрируется», «создаёт проект», «добавляет участников», «комментирует». Каждое существительное в этих фразах — кандидат в таблицу: User, Project, Member, Comment. Каждое отношение между существительными — связь. «Юзер создаёт проекты» = User имеет много Project (1:N). «Юзер участвует в проектах» = User ↔ Project через Member (N:M). Этот процесс называется domain modeling — и он важнее, чем знание SQL.
Нормализация: правило «одно место — одна правда»
Нормализация — принцип «каждый факт хранится в одном месте». Плохо: в таблице orders колонки customer_name, customer_email — потому что когда юзер обновит email, в старых заказах останется старый. Правильно: orders ссылается на users по userId, имя и email хранятся ТОЛЬКО в users. Когда юзер обновится — все заказы автоматически «увидят» новые данные через JOIN. Это первая нормальная форма (1НФ): не дублируй данные, ссылайся на источник.
Когда осознанно денормализовать
Иногда нормализация мешает. Пример: в посте блога ты хочешь показать имя автора. С нормализацией — каждый раз JOIN с users. Если постов 100 на странице, это 100 JOIN. Решение: денормализация — копируешь author_name прямо в posts. Минус: при смене имени надо обновить во всех постах. Плюс: запрос быстрее в 10 раз. Когда денормализовать осознанно: 1) поле почти не меняется (имя автора, заморозка цены в заказе), 2) запрос — горячий (главная страница), 3) альтернатива — сложный запрос или N+1. Без этих условий — нормализуй.
Ключи: первичный, внешний, составной
Primary key (PK) — уникальный идентификатор строки. Используй cuid()/uuid() вместо автоинкремента (1,2,3...) для публичных id — иначе по url /order/123 видно сколько у тебя заказов всего. Foreign key (FK) — поле, ссылающееся на PK другой таблицы (orders.user_id → users.id). FK гарантирует ссылочную целостность: нельзя создать заказ для несуществующего юзера, нельзя удалить юзера если есть его заказы (или удалятся каскадом). Composite key — уникальность по комбинации полей: enrollment(student_id, course_id) — студент не может дважды записаться на тот же курс.
Антипаттерны схемы — список того, чего НЕ делать
JSON-колонка вместо отдельной таблицы. Хранишь user.permissions как JSON-массив — поиск «у кого есть permission X» теперь требует full-scan и regexp. Размытые поля типа status: string без enum или constraint — через год там 47 разных значений с опечатками. Хранение паролей открытым текстом — passwordHash или удаляешь юзеров. Поля типа address — на самом деле это страна, город, улица, дом — 4 колонки или связанная таблица addresses. Float для денег — копейки теряются на округлениях, используй integer (хранить копейки) или decimal. Эти 5 антипаттернов покрывают 80% проблем со схемой.
// ❌ ПЛОХО — всё свалено в одну таблицу, дубли, нет ссылок
model Order {
id String @id
customerEmail String // дубль из User
customerName String // дубль из User
items String // JSON-строка с товарами — нельзя искать
status String // "pending", "Pending", "PENDING" — будет хаос
totalDollars Float // float для денег — потеря копеек
}
// ✅ ХОРОШО — нормализовано, типизировано, со связями
model User {
id String @id @default(cuid())
email String @unique
name String
orders Order[]
}
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
totalCents Int // в копейках, без потерь
status OrderStatus // enum, фиксированные значения
items OrderItem[]
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String
quantity Int
priceCents Int // снимок цены НА МОМЕНТ заказа — осознанная денормализация
}
enum OrderStatus {
pending
paid
shipped
delivered
cancelled
}Plохая схема vs хорошая — сторона спроса (e-commerce)