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: @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 зберігати його в базу за кодом, а не за ім’ям. Це і є типовий сценарій використання для AttributeConverter.

Щоб запам’ятати було простіше, тримайте коротку підказку: @Enumerated — це «enum як є», а converter — це «enum (або будь-який доменний тип) через перекладача».

3. AttributeConverter<X, Y> як «перекладач» X↔Y

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

Найважливіша думка тут: converter не створює нову таблицю і не перетворює значення на entity. Він просто допомагає Hibernate зрозуміти, що записувати в колонку і як відновлювати значення під час читання.

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

flowchart LR
    A["Поле сутності: 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("Невідомий код статусу: " + code);
    };
}

Тут ми робимо важливу річ: якщо в БД лежить невідомий код, ми не мовчки повертаємо null, а падаємо зі зрозумілою помилкою. Так, це може «зламати запит», але зате ви одразу дізнаєтеся, що дані в базі неконсистентні. У курсі глибокого занурення це вважається плюсом: ми хочемо бачити проблему, а не ховати її під килим.

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 = ...), то код стає трохи багатослівнішим, зате максимально передбачуваним: відкриваєте сутність — і відразу бачите, що відбувається з полем під час збереження. Це хороший варіант для навчання та для проєктів, де цінується прозорість.

Якщо ж ви хочете, щоб 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 і ви змінюєте його внутрішність потайки, ви ризикуєте отримати дуже неприємні ефекти. В одному місці зміни можуть бути не помічені, якщо equals не відображає внутрішній стан, в іншому місці — навпаки з’являться випадкові оновлення, тому що об’єкт мутував десь збоку. У попередній лекції ми вже говорили, що для value objects краще стиль whole-value replacement, і з converter-типами це правило теж працює.

Ось приклад того, як робити не слід: mutable доменний тип, який хочеться зберігати в одному стовпці:

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 не може бути порожнім");
        }
    }
}

І 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
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ