← назад к разделу

Hibernate — это ORM-библиотека: она берёт объект Java и сама транслирует его в строку таблицы (и обратно). Чтобы это работало, нужно описать, как именно поля класса соответствуют колонкам. Этот процесс и называют маппингом сущностей.

поля объекта раскладываются по колонкам одной строки class Customer строка в customers @Id @Column @Enumerated @Embedded Long id String firstName Status status Address address id first_name status = 'ACTIVE' city street postal_code 1 2 3 4 5 String displayLabel Long idid String firstNamefirst_name Status statusstatus = 'ACTIVE' Address addresscitystreetpostal_code @Transient — колонки нет

Одна строка таблицы собирается из полей объекта. Имя колонки задаёт @Column или стратегия именования; @Enumerated(STRING) кладёт в колонку имя константы; встроенный объект @Embedded разворачивается в несколько колонок той же строки, а поле с @Transient не доезжает до базы вовсе.

Обязательно

Что такое сущность

Сущность (entity) — это обычный Java-класс, который Hibernate умеет сохранять в базу данных и загружать из неё. Минимальные требования: аннотация @Entity, конструктор без аргументов (может быть protected) и поле с @Id.

Ещё два ограничения обычно вспоминают, только когда они уже сломались: сам класс не должен быть final, а поля, которые маппятся на колонки, — final или static. Hibernate подменяет сущность наследником-прокси и следит за изменением полей; с final ни то ни другое не работает.

import jakarta.persistence.*;

@Entity
@Table(name = "products")
public class Product {

    @Id
    private Long id;

    private String name;
}

@Table(name = "products") задаёт имя таблицы явно. Без неё Hibernate использует имя класса — поведение зависит от настройки hibernate.physical_naming_strategy, поэтому явное @Table надёжнее.

Первичный ключ и генерация идентификатора

Каждая сущность обязана иметь @Id. Чтобы Hibernate генерировал значение автоматически, добавляют @GeneratedValue.

Стратегий три, и различаются они тем, кто и когда назначает число: база в момент вставки, база заранее по запросу Hibernate или Hibernate по умолчанию:

СтратегияКак работает
IDENTITYПолагается на AUTO_INCREMENT / GENERATED ALWAYS AS IDENTITY в БД. Hibernate вставляет строку и потом читает сгенерированный ключ.
SEQUENCEИспользует объект-последовательность в БД. Hibernate запрашивает следующий ID заранее.
AUTOДля числового ключа Hibernate 6 всегда берёт последовательность и, если явного генератора нет, ждёт в базе <имя_таблицы>_seq — её придётся завести миграцией.

Почему SEQUENCE предпочтительнее IDENTITY? При IDENTITY Hibernate не знает ID до выполнения INSERT — это блокирует батчинг (JDBC batch). Со стратегией SEQUENCE ID запрашивается заранее через nextval, поэтому несколько INSERT уходят в базу одним пакетом.

Разница видна там, где строки вставляют пачками. На единичных вставках её не заметить, поэтому IDENTITY в простых примерах — не ошибка, а самый короткий способ показать маппинг.

IDENTITY persist() INSERT база вернула id по одной вставке SEQUENCE nextval блок из 50 persist() INSERT пачкой

Два пути к идентификатору: при IDENTITY число приходит только после INSERT, поэтому вставки уходят по одной, а при SEQUENCE номера берутся блоком заранее и складываются в пакет.

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
@SequenceGenerator(name = "product_seq", sequenceName = "product_id_seq", allocationSize = 50)
private Long id;

allocationSize = 50 означает: Hibernate резервирует блок из 50 значений за один запрос к последовательности и раздаёт их по одному — при интенсивной вставке это экономит обращения к БД.

И вот условие, на котором этот раздел ломается чаще всего: шаг самой последовательности в базе обязан совпадать с allocationSize. Если миграция создала её обычной, с шагом 1, Hibernate всё равно считает полученное от nextval число вершиной блока из 50 и начинает раздавать идентификаторы, которые база потом выдаст кому-то ещё, — приложение ловит duplicate key. В миграции пишите шаг явно, и меняйте два числа всегда вместе:

CREATE SEQUENCE product_id_seq INCREMENT BY 50;

Если идентификатор — UUID, см. статью UUID в PostgreSQL — там разобраны типы uuid и стратегии генерации на уровне базы.

Колонки: @Column

По умолчанию Hibernate маппит каждое поле на колонку с тем же именем (с учётом стратегии именования). @Column позволяет явно задать параметры:

@Column(name = "product_name", nullable = false, length = 255)
private String name;

@Column(name = "price", precision = 10, scale = 2)
private BigDecimal price;

@Column(name = "in_stock", columnDefinition = "boolean default true")
private boolean inStock;

Важные атрибуты:

  • nullable = false — добавляет NOT NULL в DDL, если Hibernate генерирует схему. Но чисто схемной аннотацией это не остаётся: когда в проекте нет hibernate-validator, Hibernate сам сверяет такие поля перед походом в базу и на пустом бросает PropertyValueException. Как только Bean Validation появляется в classpath, свою проверку он выключает и пустое поле ловит уже @NotNull. Связь работает и в обратную сторону — увидев @NotNull, Hibernate поставит NOT NULL в сгенерированной схеме.
  • length — максимальная длина для VARCHAR (по умолчанию 255).
  • precision / scale — точность для NUMERIC.
  • insertable = false / updatable = false — Hibernate не включает поле в INSERT / UPDATE. Используется, например, для колонок, управляемых триггерами.

Перечисления: ловушка @Enumerated

Перечисление (enum) Hibernate хранит одним из двух способов: порядковым номером константы или её именем. Выбирает @Enumerated, и по умолчанию — если аннотацию не поставить вовсе или написать без параметра — действует ORDINAL, то есть номер.

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Status status;

Чем опасен номер, видно на модели: в базе лежит число, а смысл числа живёт в Java-коде. Стоит добавить константу в середину перечисления — и старые строки начинают читаться неправильно, причём молча, без единой ошибки.

живой пример

public class EnumStorageDemo {

    enum StatusV1 { NEW, PAID, SHIPPED }

    enum StatusV2 { NEW, ON_HOLD, PAID, SHIPPED }

    public static void main(String[] args) {
        StatusV1 saved = StatusV1.PAID;
        int ordinalColumn = saved.ordinal();
        String stringColumn = saved.name();
        System.out.println("записали " + saved + ": ORDINAL=" + ordinalColumn + ", STRING=" + stringColumn);
        System.out.println("добавили ON_HOLD вторым и читаем те же строки:");
        System.out.println("  ORDINAL -> " + StatusV2.values()[ordinalColumn]);
        System.out.println("  STRING  -> " + StatusV2.valueOf(stringColumn));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Оплаченный заказ превратился в отложенный, и об ошибке никто не сообщил. Со строкой такого не случается: в колонке лежит имя константы, и смысл значения виден прямо в базе.

живой пример

SELECT status, count(*) AS orders FROM orders GROUP BY status ORDER BY status;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В выдаче стоят NEW, PAID, SHIPPED, а не 0, 1, 2: отчёт по статусам читается без заглядывания в Java-код.

Правило: EnumType.STRING — всегда. Если колонка с порядковыми номерами уже в проде, на строки переходят только миграцией с переносом значений: добавить текстовую колонку, заполнить её по таблице соответствия номеров, переключить код, удалить старую. А до миграции новые константы добавляют только в конец перечисления, иначе старые номера поменяют смысл.

Обратная сторона у STRING тоже есть, и знать её стоит заранее. Значение занимает больше места (строка вместо одного байта) — на десятках миллионов строк это заметно, и тогда колонку объявляют varchar(32), а не varchar(255). Переименование константы в коде ломает старые строки: в базе останется старое слово, и при чтении прилетит IllegalArgumentException: No enum constant, причём на чтении чужой строки, а не на записи. А миграция с ORDINAL на STRING на живой таблице делается отдельным шагом: добавить колонку, перелить значения по соответствию номер-имя (соответствие берут из того порядка констант, который был на момент записи, а не из текущего), переключить маппинг, удалить старую колонку.

Отсюда практическое правило: константы перечисления, попавшие в базу, переименовывают так же осторожно, как колонки, — через расширение и сжатие схемы, а не правкой кода.

Встраиваемые объекты: @Embedded и @Embeddable

Иногда несколько колонок одной таблицы логически образуют группу — например, адрес. Вместо того чтобы складывать всё в плоский класс-сущность, можно выделить встраиваемый объект (@Embeddable):

@Embeddable
public class Address {
    private String city;
    private String street;

    @Column(name = "postal_code", length = 10)
    private String postalCode;
}

В сущности Customer поле с таким типом помечают @Embedded:

@Embedded
private Address address;

Hibernate хранит city, street, postal_code прямо в таблице customers — никакой дополнительной таблицы нет. В Java-коде адрес при этом остаётся самостоятельным объектом со своей логикой валидации.

Если один @Embeddable-тип используется в сущности дважды (например, deliveryAddress и billingAddress), имена колонок нужно переопределить через @AttributeOverrides:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "city", column = @Column(name = "billing_city")),
    @AttributeOverride(name = "street", column = @Column(name = "billing_street")),
    @AttributeOverride(name = "postalCode", column = @Column(name = "billing_postal_code"))
})
private Address billingAddress;

@Version: поле, которое понадобится дальше

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

@Version
private long version;

Колонка обычная (bigint или integer), значение увеличивает сам Hibernate при каждом обновлении строки, а в WHERE каждого UPDATE добавляется условие по старому значению. Если строку тем временем изменил кто-то другой, обновится ноль строк, и Hibernate бросит OptimisticLockException. Полностью этот механизм разбирается в статье про транзакции и блокировки; здесь важно помнить, что поле объявляется тут, в маппинге, и что его не ставят вручную в коде.

@Lob и тяжёлые поля

Колонка с текстом договора или содержимым файла читается раз в месяц, а приезжает при каждой загрузке сущности. Пометка @Lob говорит, что это большой объект (text или bytea в PostgreSQL), а @Basic(fetch = FetchType.LAZY) — что грузить его сразу не надо:

@Lob
@Basic(fetch = FetchType.LAZY)
private String documentBody;

Оговорка обязательная: ленивость обычного поля работает только при включённом преобразовании байт-кода (hibernate.enhancer.enableLazyInitialization, в Spring Boot — плагин сборки). Без него аннотация молча игнорируется, и поле по-прежнему грузится всегда. Проверяется это просто — по журналу запросов: в списке колонок либо есть document_body, либо нет.

Альтернатива без байт-кода, которая часто оказывается честнее: вынести тяжёлое поле в отдельную сущность с той же таблицей-владельцем (связь один к одному) или вообще не читать его сущностью — брать отдельным запросом, когда понадобится.

Lombok на сущности: что можно, а что ломает

@Data на сущности — самая дорогая привычка в этой теме, потому что разворачивается сразу в три проблемы.

@EqualsAndHashCode по всем полям тянет в сравнение и связи: сравнение двух заказов загрузит их коллекции позиций, а вне транзакции — уронит LazyInitializationException. Вдобавок hashCode у новой и у сохранённой сущности разный (после вставки появился идентификатор), и объект, положенный в множество до сохранения, в нём «теряется».

@ToString по всем полям делает то же самое в журнале: одна строчка отладочного вывода тянет из базы все связи, а при двунаправленной связи уходит в бесконечную рекурсию.

@Setter на всех полях открывает изменение идентификатора и версии — того, что менять не должен никто.

Рабочий набор для сущности такой: @Getter, точечные сеттеры (или методы предметной области вместо них), @NoArgsConstructor(access = AccessLevel.PROTECTED) для Hibernate и отдельный конструктор для своего кода. equals и hashCode пишут руками — как именно, разобрано в статье про типичные ошибки; @ToString либо не ставят вовсе, либо с явным списком простых полей.

Поля без маппинга: @Transient

Если поле нужно в Java-классе, но сохранять его в базу не нужно — добавляют @Transient:

@Transient
private String displayLabel; // вычисляется на лету, не хранится

Без @Transient Hibernate посчитает поле обычным и возьмёт под него колонку. Имя колонки он не подбирает «на глаз» — его детерминированно считает стратегия именования: displayLabel превратится в display_label, и дальше вариантов ровно два. Такой колонки в таблице нет — приложение упадёт: сразу на старте, если включена сверка схемы, или на первом же запросе к таблице. Колонка есть — и это хуже: вычисляемое значение начнёт молча писаться в неё, ошибки нет, а в базе копится мусор.

Базовые типы и преобразования

Для обычного поля объявлять ничего не нужно: строки, числа, BigDecimal, Boolean, даты и время из java.time и UUID Hibernate 6 кладёт в колонки сам, поддержка Java Time API в нём встроена. Разговор начинается там, где тип в базе и в коде не совпадает.

Для нестандартных типов есть @Convert с реализацией AttributeConverter<X, Y> — она описывает, как значение превращается в колонку и обратно. Ниже список тегов складывается в одну текстовую колонку через запятую:

@Converter(autoApply = false)
public class StringListConverter implements AttributeConverter<List<String>, String> {

    @Override
    public String convertToDatabaseColumn(List<String> list) {
        return list == null ? null : String.join(",", list);
    }

    @Override
    public List<String> convertToEntityAttribute(String value) {
        if (value == null || value.isBlank()) {
            return new ArrayList<>();
        }
        return new ArrayList<>(Arrays.asList(value.split(",")));
    }
}

Две мелочи в этом методе стоят отдельного слова, потому что обе выстреливают уже в проде. Первая: "".split(",") возвращает не пустой массив, а массив из одной пустой строки — поэтому пустую колонку надо отсеять отдельной проверкой, иначе у товара появится тег из ничего. Вторая: список, который вернул конвертер, кладётся прямо в поле сущности, и прикладной код будет звать у него add(). Отдайте List.of(...) — и первый же product.getTags().add("new") упадёт с UnsupportedOperationException.

@Convert(converter = StringListConverter.class)
@Column(name = "tags")
private List<String> tags;
Дополнительно: при первом чтении можно пропустить

Глубже: Hibernate и схема базы: ddl-auto, validate и миграциирасширенное

Кто создаёт таблицы? Новичок видит, что при первом запуске они появились сами, и решает, что так и надо. Это spring.jpa.hibernate.ddl-auto=create или update: Hibernate сравнивает сущности со схемой и правит базу по своему разумению. Для стенда с тестами это удобно, для боевой базы недопустимо: update не удаляет колонки и не меняет типы, а создаёт таблицы без нужных индексов и с именами по своим правилам; через полгода схема в проде не совпадает ни с сущностями, ни с ожиданиями.

Схемой владеют миграции, Liquibase или Flyway: они выполняются при старте до того, как Hibernate посмотрит на базу, и каждое изменение лежит в репозитории с номером. Hibernate при этом ставят в режим проверки:

spring:
  jpa:
    hibernate:
      ddl-auto: validate

validate при старте сверяет, что у каждой сущности есть таблица, у каждого поля колонка, и типы совместимы; расхождение роняет приложение с понятным сообщением вроде Schema-validation: missing column [delivered_at] in table [orders]. Это дешёвая страховка от миграции, которую написали, но не применили, и от сущности, которой забыли добавить миграцию. Сверка не идеальна: она смотрит на тип грубо и пропустит timestamp вместо timestamptz, зато ловит всё, что ломается на первом запросе. Порядок такой: сначала миграция, потом правка сущности, потом validate на стенде подтверждает, что они сошлись.

Глубже: составные и естественные ключи: @EmbeddedId, @IdClass, @NaturalIdрасширенное

Вся фаза знает один ключ, Long id. Два случая требуют другого. Первый: ключ из нескольких колонок, как у строки заказа (order_id, line_no) или у связки «пользователь и роль». Для него есть @EmbeddedId с классом-ключом:

@Embeddable
public record OrderLineId(UUID orderId, int lineNo) implements Serializable {}

@Entity
public class OrderLine {
    @EmbeddedId
    private OrderLineId id;
}

Класс ключа обязан быть Serializable с честными equals и hashCode, и record даёт это бесплатно (Hibernate 6.2 и новее умеет @Embeddable на записях). Второй вариант, @IdClass, оставляет поля ключа прямо в сущности, а класс нужен только для find; он остался от старых времён, и @EmbeddedId читается лучше. Тем же @Embeddable оформляют идентификатор как объект-значение: OrderId с одним полем UUID внутри вместо голого UUID, чтобы компилятор не дал перепутать идентификатор заказа с идентификатором покупателя, это основа агрегатов из следующих фаз.

Второй случай: у сущности есть суррогатный ключ, но искать её приходится по естественному, по email или по номеру заказа. @NaturalId на поле объявляет такой ключ: Hibernate создаст уникальный индекс и даст загрузку по нему, session.bySimpleNaturalId(Customer.class).load(email), с кэшированием соответствия «email → id» во втором уровне. Без него поиск по email это обычный запрос, что тоже работает, но не сообщает Hibernate, что поле уникально и неизменно.

Глубже: аудит и мягкое удалениерасширенное

Две колонки, которые есть почти в каждой таблице, created_at и updated_at, не должны заполняться руками в каждом сервисе. Два способа. Hibernate ставит их сам аннотациями @CreationTimestamp и @UpdateTimestamp на полях, значение берётся в момент вставки и обновления. Spring Data JPA умеет больше: @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy при включённом @EnableJpaAuditing и слушателе на сущности:

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
    @CreatedDate  private Instant createdAt;
    @LastModifiedDate private Instant updatedAt;
    @CreatedBy private String createdBy;   // из AuditorAware: текущий пользователь
}

Общие поля выносят в @MappedSuperclass, чтобы не повторять их в каждой сущности. Время берите как Instant: в базе это timestamptz, и часовой пояс приложения на него не влияет.

Мягкое удаление, когда строку помечают удалённой вместо DELETE, в Hibernate 6.4 стало штатным: @SoftDelete на сущности добавляет колонку deleted, превращает remove в UPDATE и прячет помеченные строки из всех запросов. В старых версиях то же собирали из @SQLDelete с запросом обновления и @SQLRestriction("deleted = false"). Цена мягкого удаления не в Hibernate: уникальный индекс по email перестаёт работать (удалённый и новый покупатель с одним email), и его переделывают в частичный, WHERE deleted = false; связи из других таблиц на «удалённую» строку остаются живыми; отчёты и нативные запросы фильтр Hibernate не видят и обязаны фильтровать сами.

Глубже: типы PostgreSQL в сущности: JSONB, массивы, enum, timestamptzрасширенное

AttributeConverter из основной части покрывает простые случаи, а у PostgreSQL есть типы, для которых Hibernate 6 знает маппинг сам.

JSONB: поле любого типа, который умеет сериализовать Jackson, помечают @JdbcTypeCode(SqlTypes.JSON), и Hibernate пишет его в колонку jsonb, а читает обратно в объект. Так хранят атрибуты товара, которые различаются по категориям, и не заводят по колонке на каждый.

@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> attributes;

Массивы: String[] или List<String> без аннотаций ложатся в text[]; поиск по ним в JPQL ограничен, для @> пишут нативный запрос. Перечисления базы: если колонка объявлена как CREATE TYPE order_status AS ENUM (...), Java-enum маппят через @JdbcTypeCode(SqlTypes.NAMED_ENUM) (Hibernate 6.2 и новее), иначе Hibernate попытается писать строку в колонку типа enum и упадёт на несовпадении типов. Обычно проще держать статус текстом с CHECK, о чём говорит статья про перечисления в разделе PostgreSQL.

Время: колонка timestamptz соответствует Instant или OffsetDateTime, а LocalDateTime соответствует timestamp без зоны и теряет смысл момента. Чтобы Hibernate не пересчитывал время в зону JVM при чтении, задают spring.jpa.properties.hibernate.jdbc.time_zone=UTC; тогда одно и то же значение читается одинаково на любом сервере.

Коротко

  • @Entity + конструктор без аргументов + @Id — минимум для сущности.
  • SEQUENCE лучше IDENTITY для высоких нагрузок: позволяет JDBC-батчинг.
  • @Enumerated(EnumType.STRING) — всегда; ORDINAL ломается при изменении порядка констант, но и STRING не бесплатен: переименование константы рушит чтение старых строк, а переезд с ORDINAL делают отдельной миграцией.
  • @Embedded / @Embeddable — группируют колонки в объект без новой таблицы.
  • @Transient — поле в памяти, не в базе; @Version объявляют здесь же, а тяжёлое поле прячут под @Lob с @Basic(fetch = LAZY) — и только при включённом преобразовании байт-кода.
  • Для нестандартных типов — AttributeConverter<X, Y>. @Data от Lombok на сущности запрещён: equals по связям, рекурсия в toString и сеттер на идентификатор; берут @Getter и защищённый конструктор без аргументов.
  • Схемой владеют миграции, Hibernate в проде только проверяет: ddl-auto: validate; update в проде рано или поздно расходится с базой.
  • Составной ключ это @EmbeddedId с record, естественный уникальный ключ @NaturalId; идентификатор-объект вместо голого UUID не даёт перепутать сущности.
  • created_at и updated_at заполняют @CreationTimestamp или аудит Spring Data; мягкое удаление через @SoftDelete требует частичных уникальных индексов и фильтров в нативных запросах.
  • JSONB через @JdbcTypeCode(SqlTypes.JSON), массивы напрямую, enum базы через SqlTypes.NAMED_ENUM, timestamptz как Instant с hibernate.jdbc.time_zone=UTC.

Что почитать дальше