JavaRush /Курсы /Hibernate deep-dive /AttributeConverter и...

AttributeConverter и маппинг enum

Hibernate deep-dive
14 уровень , 3 лекция
Открыта

1. Доменный тип в Java и один столбец в БД

Почти любая зрелая модель данных рано или поздно упирается в конфликт двух миров. В Java нам хочется иметь осмысленные типы: ProductStatus, Sku, Email, PhoneNumber, OrderNumber — чтобы код читался как предметная область, а не как “клуб любителей строк”. В базе данных нам часто нужен один столбец: status, sku, email. И вот тут начинаются два классических перекоса: либо мы сдаёмся и храним всё строками, либо плодим сущности там, где сущность не нужна.

Если хранить доменные значения “как попало” (например, статус как String), код превращается в сериал “угадай формат”. В одном месте кто-то напишет "ACTIVE", в другом "active", в третьем "A", а потом вы будете дебажить это в 2 ночи и думать, почему IT вообще не пошёл в садоводы. Если же попытаться сделать для статуса отдельную entity со своим id, вы получаете лишний lifecycle, лишние связи и лишние SQL-вопросы — и всё ради того, что по смыслу является одним значением.

Нам нужен инструмент, который честно говорит: “в Java это тип X, в БД это тип Y и один столбец, а преобразование я опишу явно”. В JPA этот инструмент называется AttributeConverter<X, Y>.

До этого мы разбирали значения, которые распадаются на несколько колонок, как Money и Address. Для них естественный инструмент — @Embeddable. Но статус, SKU или email-код — это тот же вопрос типизации, только с другой формой хранения: в Java хочется осмысленный тип, а в таблице нужен один столбец. Здесь и начинается ветка @Enumerated / AttributeConverter.

И прежде чем до него дойти, полезно вспомнить самый простой случай — enum mapping.

2. Enum mapping: @Enumerated(EnumType.STRING)

Когда речь идёт о статусах, первое, что приходит в голову — enum. И это хорошая мысль: статус чаще всего действительно ограничен конечным набором значений. Но важно, как этот enum будет храниться в базе. На практике у JPA есть два режима: ORDINAL и STRING. И один из них похож на “взять бензопилу, чтобы открыть пачку печенья”.

Начнём с нормального, безопасного варианта — EnumType.STRING. Он хранит имя enum-константы как строку. Да, это чуть больше места в столбце, зато гораздо стабильнее и читаемее.

Мини-пример:

public enum CustomerStatus {
    // В БД будет храниться строка "ACTIVE" или "BLOCKED" (если используем EnumType.STRING)
    ACTIVE,
    BLOCKED
}
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;

// Явно говорим JPA: храним имя константы (а не её порядковый номер)
@Enumerated(EnumType.STRING)
private CustomerStatus status;

Если включить SQL trace, вы увидите что-то в духе:

update customer set status='BLOCKED' where id=...

И это читается не только Hibernate-ом, но и человеком, который “просто открыл таблицу посмотреть”.

Теперь про ORDINAL. Он хранит номер (0, 1, 2…) константы в enum. Это удобно ровно до того момента, пока кто-то не добавит новый статус в середину или не поменяет порядок констант. Тогда ваш “BLOCKED” внезапно превращается в “ACTIVE”, а “ACTIVE” — в “DELETED”, и вы получите бизнес-апокалипсис без единого исключения. Это как хранить должности сотрудников по номеру в списке: сегодня 0 — директор, а завтра вы случайно отсортировали список, и 0 стал стажёром.

Поэтому базовое правило курса: если вы храните enum напрямую, то @Enumerated(EnumType.STRING) — почти всегда лучший старт.

Когда @Enumerated не подходит: коды вместо имён

С EnumType.STRING всё хорошо, пока вас устраивает хранение полных имён. Но реальный мир иногда подкидывает особенности. Например, в схеме уже есть столбец status char(1) со значениями A, H, D. Или продуктовые требования говорят: “в таблице должно быть ровно два символа, потому что так принято в этом домене”. Или вы хотите хранить компактные коды, потому что это часть внешнего контракта с отчётами/интеграциями.

И вот тут у вас дилемма. Либо вы делаете статус строкой и вручную маппите коды туда-сюда по всему коду (плохой вариант). Либо вы продолжаете работать с enum в Java, но учите Hibernate сохранять его в базу по коду, а не по имени. Это и есть типичный use case для AttributeConverter.

Чтобы было проще запомнить, держите короткую интуицию: @Enumerated — это “enum как есть”, а converter — это “enum (или любой доменный тип) через переводчика”.

3. AttributeConverter<X, Y> как “переводчик” X↔Y

AttributeConverter<X, Y> — это интерфейс JPA, который говорит: “в entity у меня тип X, но в базе я храню тип Y”. На практике Y чаще всего примитивный для JDBC мир: String, Integer, Long, BigDecimal, LocalDate и так далее. А X — ваш доменный тип: enum с кодом, value-like обёртка, иногда даже небольшой объект (но строго в один столбец).

Самая важная мысль здесь: converter не создаёт новую таблицу и не превращает значение в entity. Он просто помогает Hibernate понять, что записывать в колонку и как восстановить значение при чтении.

Схема работы выглядит примерно так:

flowchart LR
    A["Entity поле: X (доменный тип)"] -->|convertToDatabaseColumn| B["Колонка: Y (тип хранения)"]
    B -->|convertToEntityAttribute| A

В интерфейсе всего два метода, и названия у них максимально “говорящие”:

convertToDatabaseColumn(X attribute) — что записать в БД

convertToEntityAttribute(Y dbData) — что получить в Java при чтении

Важно: converter — это технический слой маппинга, а не место для бизнес-логики. Он должен переводить значения, проверять корректность формата и падать понятной ошибкой, если в базе лежит мусор. Но он не должен “исправлять” данные или делать умные доменные решения.

4. Пример: кодовый статус и ProductStatusConverter

Давайте сделаем пример максимально близким к проекту: у Product есть статус. Для простоты возьмём два состояния: товар активен и товар скрыт. В Java нам хочется писать ProductStatus.ACTIVE, а в БД — хранить короткий код (A или H). Это ровно тот сценарий, ради которого converter и существует.

Enum со стабильным кодом

public enum ProductStatus {
    // Стабильный код для хранения в БД (не зависит от имени константы)
    ACTIVE("A"),
    HIDDEN("H");

    private final String code;

    // Код задаём один раз при объявлении константы
    ProductStatus(String code) { this.code = code; }

    public String getCode() { return code; }
}

Чтобы уметь восстанавливать enum из базы, нам нужен обратный перевод. В Java 25 удобно использовать switch:

public static ProductStatus fromCode(String code) {
    // Переводим значение из БД в доменный enum
    return switch (code) {
        case "A" -> ACTIVE;
        case "H" -> HIDDEN;
        // Если в БД лежит неизвестный код — лучше упасть сразу и явно
        default -> throw new IllegalArgumentException("Unknown status code: " + code);
    };
}

Здесь мы делаем важную вещь: если в БД лежит неизвестный код, мы не молча возвращаем null, а падаем с понятной ошибкой. Да, это может “сломать запрос”, но зато вы сразу узнаете, что данные в базе неконсистентны. В deep-dive курсе это считается плюсом: мы хотим видеть проблему, а не прятать её под ковёр.

Converter

Теперь пишем сам converter. Обратите внимание: это jakarta.persistence, потому что мы в современном стеке (Spring Boot 4, Hibernate 7.2).

import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter
public class ProductStatusConverter implements AttributeConverter<ProductStatus, String> {

    @Override
    public String convertToDatabaseColumn(ProductStatus status) {
        // Java enum -> значение, которое реально запишется в колонку (например, "A")
        return status == null ? null : status.getCode(); // ACTIVE -> "A"
    }

    @Override
    public ProductStatus convertToEntityAttribute(String dbValue) {
        // Значение из БД -> доменный enum (например, "A" -> ACTIVE)
        return dbValue == null ? null : ProductStatus.fromCode(dbValue); // "A" -> ACTIVE
    }
}

Тут два маленьких, но важных решения. Во-первых, мы корректно обрабатываем null, потому что в реальной жизни колонки могут быть nullable (особенно на ранних этапах проекта). Во-вторых, мы не пытаемся “улучшить” данные: если dbValue неизвестный — пусть fromCode(dbValue) бросит исключение.

Подключение converter к полю entity

Теперь на сущности Product мы указываем, какой converter использовать:

import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class Product {
    @Id
    private Long id;

    // Явно подключаем converter именно к этому полю
    @Convert(converter = ProductStatusConverter.class)
    private ProductStatus status;
}

Если теперь сохранить товар со статусом ACTIVE, в SQL вы увидите что-то вроде:

insert into product (status, id) values ('A', ?)

То есть в Java вы продолжаете жить в мире ProductStatus, а база хранит ровно то, что вам нужно по схеме.

5. Подключение converter: @Convert и autoApply

Когда вы поняли механику, появляется следующий практический вопрос: “Я должен ставить @Convert на каждое поле? Или можно один раз объявить converter, и пусть он применяется везде?” Оба варианта допустимы — но у каждого есть характер и побочные эффекты.

Если вы указываете converter явно через @Convert(converter = ...), то код становится чуть более многословным, зато максимально предсказуемым: открываете entity — и сразу видите, что происходит с полем при сохранении. Это хороший вариант для обучения и для проектов, где ценится прозрачность.

Если же вы хотите, чтобы converter применялся “по умолчанию” для всех полей определённого типа, можно поставить autoApply = true:

import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter(autoApply = true)
public class ProductStatusConverter implements AttributeConverter<ProductStatus, String> {
    public String convertToDatabaseColumn(ProductStatus a) { return a == null ? null : a.getCode(); }
    public ProductStatus convertToEntityAttribute(String db) { return db == null ? null : ProductStatus.fromCode(db); }
}

Тогда вам уже не нужно писать @Convert на каждом поле типа ProductStatus. Hibernate увидит: “Ага, для ProductStatus есть auto-apply converter — значит, применяем”.

Чтобы не было ощущения “религии”, сравним это инженерно:

Подход Как выглядит в коде Главный плюс Главный риск
@Convert на поле Явно на каждом поле Очень прозрачно, легко ревьюить Больше аннотаций
@Converter(autoApply = true) В одном месте Меньше шума в entity Можно случайно применить там, где вы не ожидали

В учебном проекте (и особенно на вашем этапе) я бы предпочёл явность: сначала @Convert, а уже когда вы уверены, что тип действительно всегда хранится одинаково — можно думать про autoApply.

6. Converter и dirty checking: связь с иммутабельностью

Можно подумать: “Ну converter же просто переводит значение, причём тут dirty checking?” А связь есть, и она довольно практическая. Hibernate решает, делать ли UPDATE, сравнивая текущее значение поля с тем, что было загружено (snapshot). Для базовых полей это часто выглядит как “equals по значению”.

Если ваш доменный тип иммутабельный, всё прекрасно: новое значение — новый объект, equals обычно честный, Hibernate видит изменение, делает update, вы счастливы.

Если же ваш тип mutable (setter-ы) и вы меняете его внутренности “по-тихому”, вы рискуете получить очень неприятные эффекты. В одном месте изменения могут не быть замечены (если equals не отражает внутреннее состояние), в другом месте — наоборот появятся accidental updates, потому что объект мутировал “где-то сбоку”. В предыдущей лекции мы уже говорили, что для value objects лучше стиль whole-value replacement — и с converter-типами это правило тоже работает.

Вот пример “как делать не надо” — мутируемый доменный тип, который хочется хранить в одном столбце:

public class Sku {
    private String value;

    public Sku(String value) { this.value = value; }

    public void setValue(String value) { this.value = value; } // опасно
}

А вот пример “спокойного” варианта: иммутабельная оболочка. В Java 25 удобный минимализм — record:

public record Sku(String value) {
    public Sku {
        // Валидация доменного значения происходит в одном месте
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("SKU is blank");
        }
    }
}

И converter для него получается простым и безопасным:

import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter(autoApply = true)
public class SkuConverter implements AttributeConverter<Sku, String> {
    public String convertToDatabaseColumn(Sku sku) { return sku == null ? null : sku.value(); }
    public Sku convertToEntityAttribute(String db) { return db == null ? null : new Sku(db); }
}

Теперь Product.sku может стать типом Sku, а в базе всё равно будет обычный varchar. Это делает доменную модель выразительнее, но не усложняет схему. И, что особенно приятно для нашего курса, это держит вас в контроле над dirty checking: новое SKU — новый Sku, никаких “подкрутили строку внутри объекта”.

7. Выбор: embeddable, converter, @Enumerated, String

После трёх лекций подряд про “типизацию” легко впасть в крайность: “Давайте обернём всё на свете, и у нас будет тип ProductName, тип Country, тип PostalCode, тип Street, тип House…”. Теоретически можно, но практический смысл начинается там, где тип реально снижает количество ошибок и делает код читабельнее.

В голове удобно держать простую карту:

Что вы моделируете Сколько столбцов в БД Что использовать Пример из проекта
Группа связанных полей 2+ @Embeddable Money(amount, currency), Address(...)
Одно значение, один столбец 1 AttributeConverter<X, Y> Skuvarchar, “кодовый” статус ↔ char(1)
Enum без спец-формата 1 @Enumerated(EnumType.STRING) простой статус, где имя enum устраивает
Свободный текст 1 String (иногда с валидацией) Product.name, ProductDetails.description

Обратите внимание на важную деталь: converter не заменяет embeddable и наоборот. Это просто два разных инструмента под две разные формы хранения. Money не стоит пытаться запихнуть в один столбец через converter “в формате 100.00|USD” — это будет и неудобно, и плохо для запросов, и больно для миграций. А вот Sku или “кодовый статус” — идеальные кандидаты для converter, потому что у них натуральное представление в одном столбце.

8. Типичные ошибки при работе с AttributeConverter

Ошибка №1: смешивать @Convert и @Enumerated на одном поле.
Иногда хочется “на всякий случай” навесить и то, и другое: мол, пусть и enum, и converter. JPA так не работает: у поля должен быть один понятный способ маппинга. Если вы используете converter для enum, уберите @Enumerated. Если вы используете @Enumerated(EnumType.STRING), уберите @Convert. Иначе вы получите либо ошибку маппинга, либо поведение, которое сложно объяснить даже вашему будущему “я”.

Ошибка №2: использовать EnumType.ORDINAL “потому что так меньше места”.
Сэкономленные байты потом оплачиваются часами расследований, когда порядок констант поменяли, а база осталась прежней. Если вам действительно нужно компактное хранение, лучше сделать “кодовый enum” и converter. Тогда компактность будет стабильной и осмысленной.

Ошибка №3: прятать бизнес-логику в converter.
Converter — не сервис и не доменная модель. Он не должен решать, что делать при неизвестном статусе (“а давайте будем считать его ACTIVE”). Это как спрятать правила начисления зарплаты в метод toString(): формально вы можете, но потом никто не поймёт, почему у вас бухгалтерия живёт в неожиданном месте. Converter должен только конвертировать и валидировать формат.

Ошибка №4: не обрабатывать null и получать NPE в неожиданном месте.
Даже если вы планируете сделать колонку NOT NULL, сначала это должно быть выражено в миграции и данных. Пока этого нет, converter обязан быть аккуратным к null. Иначе вы получите NPE при чтении старых данных или при сохранении сущности, которую вы только создаёте.

Ошибка №5: включить autoApply = true слишком рано и “поймать” неожиданные поля.
Автоприменение — удобная штука, но оно делает магию шире. В учебном проекте это легко приводит к ситуации “почему оно здесь тоже применилось?”. Если вы не уверены, что тип всегда хранится одинаково — начните с @Convert на поле. Это не так модно, зато в 100 раз легче отлаживать.

1
Задача
Hibernate deep-dive, 14 уровень, 3 лекция
Недоступна
Статус бронирования через @Enumerated(EnumType.STRING)
Статус бронирования через @Enumerated(EnumType.STRING)
1
Задача
Hibernate deep-dive, 14 уровень, 3 лекция
Недоступна
Короткий код статуса через AttributeConverter
Короткий код статуса через AttributeConverter
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ