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

1. Boolean — это boolean

PG-T-050 Для двух значений «да/нет» — тип boolean. Не smallint 0/1, не varchar('Y'/'N'), не char(1) с CHECK.

-- правильно
is_active   boolean NOT NULL DEFAULT true,
is_verified boolean NOT NULL DEFAULT false

-- неправильно — у каждого пять собственных «нет», и только тип boolean гарантирует одно
is_active   smallint NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
is_active   char(1)  NOT NULL DEFAULT 'Y' CHECK (is_active IN ('Y', 'N'))

Boolean занимает 1 байт, читается и пишется атомарно, не путает в SQL (WHERE is_active короче, чем WHERE is_active = 1), правильно мапится на Java boolean.

2. Три способа сделать перечисление

Допустим, у заказа есть статус: NEW, PAID, SHIPPED, DELIVERED, CANCELLED.

Вариант 1 — PG ENUM

CREATE TYPE order_status AS ENUM ('NEW', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED');

CREATE TABLE order_doc (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'NEW'
);

Плюсы:

  • Компактно (4 байта на значение).
  • Type-safety на уровне БД.
  • Хорошо смотрится в EXPLAIN, в WHERE status = 'PAID'.

Минусы:

  • Добавление нового значения дешёвое только в PG12+ (ALTER TYPE ... ADD VALUE). До 12 — пересоздание типа.
  • Удаление значения — нативно невозможно. Только пересоздание типа со всеми зависимостями (таблицы, default'ы, проверки) — большая миграция.
  • Изменение порядка значений — нативно невозможно, а порядок влияет на ORDER BY status.
  • Переименование значения возможно (ALTER TYPE ... RENAME VALUE), но в PG10+.
  • Между сервисами/репликами enum-ы должны совпадать — лишний источник миграционной координации.

Вариант 2 — reference table

CREATE TABLE order_status_dict (
    code        varchar(20) PRIMARY KEY,
    description text NOT NULL,
    sort_order  smallint NOT NULL,
    is_terminal boolean NOT NULL
);

INSERT INTO order_status_dict VALUES
    ('NEW',       'Создан',         10, false),
    ('PAID',      'Оплачен',        20, false),
    ('SHIPPED',   'Отправлен',      30, false),
    ('DELIVERED', 'Доставлен',      40, true),
    ('CANCELLED', 'Отменён',        50, true);

CREATE TABLE order_doc (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status varchar(20) NOT NULL REFERENCES order_status_dict(code) DEFAULT 'NEW'
);

Плюсы:

  • Добавление / переименование / удаление значений — обычные INSERT/UPDATE/DELETE. Миграция данных (UPDATE order SET status = 'CANCELLED' WHERE status = 'OLD_NAME') идёт без ALTER TYPE.
  • Дополнительные атрибуты (description, sort_order, is_terminal) — там же, рядом.
  • Удобно админке: справочник не требует деплоя.
  • Foreign key защищает от опечаток.

Минусы:

  • 20 байт на значение (varchar(20)) против 4 у enum. Для редких таблиц — не важно.
  • При больших таблицах — лишний джойн на каждый SELECT. Часто решается денормализацией атрибутов в основную таблицу (если is_terminal нужен в каждом запросе — копировать).

Вариант 3 — text + CHECK IN (...)

CREATE TABLE order_doc (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status text NOT NULL DEFAULT 'NEW'
        CHECK (status IN ('NEW', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED'))
);

Плюсы:

  • Никаких типов. Миграция = ALTER TABLE ... DROP CONSTRAINT ... ADD CONSTRAINT ....
  • Не нужен справочник.

Минусы:

  • Нет foreign key, нельзя ссылаться из других таблиц по логике перечисления.
  • Дополнительные атрибуты (description, sort_order) надо тащить отдельно.
  • Большой CHECK IN (...) — некрасиво и не масштабируется (10+ значений выглядит плохо).

3. Когда что брать

PG-T-051 Правила выбора:

СлучайВыбор
Список значений известен, редко меняется, не нужны атрибутыENUM
Список значений может расти, изредка переименовываться, нужны атрибуты (порядок, описание, флаги)reference table
Список валидируется только на уровне БД, простой, без атрибутов, до 5–7 значенийCHECK IN (...)
Часто меняется в админкеreference table

Практика:

  • Статусы доменных сущностей (order_status, payment_status) — обычно reference table. Они приобретают атрибуты со временем.
  • Технические перечисления (event_kind, notification_channel) — ENUM или CHECK IN.
  • Двузначные / трёхзначные коды (страна, валюта, язык) — отдельная reference table со стандартами (ISO 3166, 4217, 639).

4. Миграционные ловушки ENUM

PG-M-020 Добавление значения в enum (PG12+) — ALTER TYPE ... ADD VALUE 'X'. Делается мгновенно, но в отдельной транзакции.

ALTER TYPE order_status ADD VALUE 'PARTIALLY_REFUNDED';

Важно: в той же транзакции, где добавили значение, его нельзя сразу использовать:

BEGIN;
ALTER TYPE order_status ADD VALUE 'PARTIALLY_REFUNDED';
INSERT INTO order_doc (status) VALUES ('PARTIALLY_REFUNDED');   -- ОШИБКА
COMMIT;

Это значит — миграция enum и миграция данных, использующих новое значение, должны быть в разных changeset'ах Liquibase.

PG-M-021 Переименование значения (PG10+):

ALTER TYPE order_status RENAME VALUE 'OLD_NAME' TO 'NEW_NAME';

PG-M-022 Удаление значения нативно невозможно. Замена enum реализуется так:

  1. Создать новый тип (order_status_v2) с нужным набором значений.
  2. Добавить временную колонку с новым типом.
  3. Backfill: маппинг старых значений в новые.
  4. Дропнуть старую колонку, переименовать новую.
  5. Дропнуть старый тип.

Это две-три отдельные миграции с релизом приложения между ними. Дорого. Если предполагается, что значения будут уходить — берите reference table сразу.

5. Java-сторона: как маппить

PG-T-052 Для перечислений в Java — enum, не String.

Для PG ENUM:

  • jOOQ умеет маппить — генерируется enum-класс по типу из БД.
  • Spring Data JPA тоже умеет (@Enumerated(EnumType.STRING)).

Для reference table / CHECK IN:

  • Колонка типа text/varchar, в Java мапится на свой enum через EnumType.STRING (или конвертер jOOQ).
  • Per feedback_jooq_only — вся persistence-логика через jOOQ-генерацию + явный конвертер enum ↔ text. Свой java-enum — единственное место, где значения перечисления зафиксированы в коде; БД выступает справочником.

6. Не путайте: status vs lifecycle

Не любое поле «один из набора» — это enum. Для статусов с переходами по правилам (NEW → PAID → SHIPPED, но не NEW → SHIPPED) одного типа недостаточно — нужна state machine в коде. Тип решает только «какие значения вообще валидны», переходы — отдельная логика. См. Уровень 1 UCP — пример state machine на статусах каталожной позиции.


Чек-лист на ревью

  • [ ] Boolean — boolean, не smallint/char(1).
  • [ ] Перечисления, которые могут расти и иметь атрибуты — reference table, не PG ENUM.
  • [ ] Технические короткие перечисления — ENUM либо CHECK IN (...).
  • [ ] При ENUM миграция «добавить значение» и «использовать значение» — в разных changeset'ах.
  • [ ] В Java — enum, не String, для всех перечислений.

Связанные

  • Строки — text/varchar для значений перечисления.
  • Антипаттерны.