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> | Sku ↔ varchar, “кодовый” статус ↔ 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 раз легче отлаживать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ