Используешь Kotlin с Jakarta Persistence и думаешь, что всё работает? Возможно, до первой неожиданной ошибки. data class, val, final-классы и даже значения по умолчанию — всё это может тайно мешать корректной работе JPA. Вместе с Торбеном Янссеном в новом переводе от команды Spring АйО разберем скрытые ловушки и показывают, как настроить проект правильно, чтобы не наступить на мину.


Эта статья написана в соавторстве с Торбеном Янссеном, экспертом с более чем 20-летним опытом работы с JPA и Hibernate, автором книги «Hibernate Tips: More than 70 Solutions to Common Hibernate Problems» и новостной рассылки о JPA.

Комментарий от Михаила Поливаха

Кстати, скоро выйдет 4 версия стандарта JPA. Об этом я напишу отдельную статью.

Kotlin и Jakarta Persistence (также известный как JPA) — популярное сочетание для серверной разработки. Kotlin предлагает лаконичный синтаксис и современные языковые возможности, а Jakarta Persistence обеспечивает проверенный временем фреймворк для работы с базами данных в корпоративных приложениях.

Однако Jakarta Persistence изначально создавался для Java. Некоторые популярные особенности и концепции Kotlin — такие как безопасность при работе с null и data-классы — значительно облегчают реализацию бизнес-логики, но не всегда хорошо сочетаются со спецификацией JPA.

В этой статье представлены лучшие практики, которые помогут вам избежать проблем и построить надёжный слой работы с данными, используя Kotlin и Jakarta Persistence. И хорошая новость напоследок: IntelliJ IDEA 2026.1 будет автоматически обнаруживать многие из этих проблем, подсвечивать их предупреждениями и предлагать помощь с помощью различных инспекций.

Проектирование классов-сущностей

Спецификация Jakarta Persistence предъявляет ряд требований к классам-сущностям, которые лежат в основе того, как провайдеры JPA управляют объектами сущностей.

Класс сущности должен:

  • Предоставлять конструктор без аргументов
    Провайдер JPA использует рефлексию для вызова конструктора без аргументов с целью создания экземпляров сущностей при загрузке данных из базы данных.

  • Иметь не финальные (non-final) свойства
    При извлечении объекта сущности из базы данных провайдер сначала вызывает конструктор без аргументов, а затем устанавливает значения всех свойств. Этот процесс называется «гидратацией» (hydration).
    После этого провайдер сохраняет ссылку на объект сущности, чтобы выполнять автоматическую проверку изменений (dirty checking) — при обнаружении изменений соответствующие записи в базе данных обновляются автоматически.

  • Не быть финальным (non-final)
    Провайдер JPA часто создаёт прокси-подклассы для реализации таких функций, как ленивая (lazy) загрузка в связях @ManyToOne и @OneToOne. Чтобы это работало, класс сущности не должен быть final.

Комментарий от Михаила Поливаха

На деле, JPA спека хоть и действительно требует, чтобы класс был final, но Hibernate, исторически, умеет работать и с final классами. Только вот если класс final, то, очевидно, CGLIB наследника создать в рантайме будет уже нельзя - рантайм не позволит. Соотвественно на final классах часть фич просто отвалится, такие как Lazy Loading и т.д.

Помимо требований спецификации, широко признанной лучшей практикой считается:

  • Осторожно реализовывать методы equals, hashCode и toString
    Эти методы должны опираться только на идентификатор и тип сущности, чтобы избежать непредсказуемого поведения в контексте JPA. Подходы к корректной реализации этих методов можно найти здесь.

Эти правила легко соблюдаются в Java, но вступают в противоречие с рядом стандартных особенностей Kotlin, таких как final-классы, неизменяемые свойства и инициализация через конструктор.

В следующих разделах будет показано, как адаптировать классы на Kotlin к этим требованиям, сохраняя при этом преимущества языка.

Data-классы против сущностей

Data-классы в Kotlin предназначены для хранения данных. Они являются final по умолчанию и автоматически генерируют ряд вспомогательных методов, включая геттеры и сеттеры для всех полей, а также реализацию методов equals, hashCode и toString.

Благодаря этому data-классы отлично подходят для DTO — объектов передачи данных, которые представляют результаты запросов и не управляются провайдером JPA.

Ниже приведён типичный пример использования data-класса для выборки данных:

data class EmployeeWithCompany(val employeeName: String, val companyName: String)

val query = entityManager.createQuery("""
   SELECT new com.company.kotlin.model.EmployeeWithCompany(p.name, c.name)
    FROM Employee e
       JOIN e.company c
    WHERE p.id = :id""")

val employeeWithCompany = query.setParameter("id", 1L).singleResult;

Однако сущности отличаются тем, что являются управляемыми объектами. Это создаёт проблемы, если вы моделируете их как data-классы.

Для сущностей провайдер JPA автоматически отслеживает изменения и использует ленивую загрузку для связей. Чтобы эти механизмы работали, классы сущностей должны соответствовать требованиям спецификации Jakarta Persistence, о которых мы говорили в начале этой главы.

Как видно из следующей таблицы, из-за этого data-классы Kotlin плохо подходят для использования в качестве классов-сущностей.

Kotlin Data Class

Jakarta Persistence Entity

Class Type

Final

Должен быть открытым (не final), чтобы поставщик мог создавать подклассы-прокси.

Constructors

Основной конструктор с обязательными параметрами

Должен предоставлять конструктор без аргументов, используемый persistence провайдером.

Mutability

Immutable по умолчанию (val свойства)

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

equals and hashCode

Использует все свойства

Должен полагаться только на тип и первичный ключ.

toString

Включает все свойства

Должен ссылаться только на атрибуты с немедленной (eager) загрузкой, чтобы избежать дополнительных запросов.

Рекомендуемый подход — использовать обычные open-классы для моделирования сущностей. Они являются изменяемыми, поддерживают создание прокси и не вызывают проблем при работе с Jakarta Persistence.

@Entity
open class Person {
   @Id
   @GeneratedValue
   var id: Long? = null

   var name: String? = null
}

Нефинальные классы и конструкторы без аргументов

Как уже упоминалось ранее, спецификация Jakarta Persistence требует, чтобы классы сущностей не были final и предоставляли конструктор без аргументов.

Классы в Kotlin по умолчанию являются final и не обязаны иметь конструктор без параметров.

Но не беспокойтесь — эти требования легко выполнить, не переписывая код и не реализуя классы сущностей каким-то особым образом. Просто добавьте плагины no-arg и all-open, а также зависимость на kotlin-reflect. Это обеспечит наличие нужного конструктора и пометит аннотированные классы как open на этапе сборки.

В настоящее время вам понадобятся компиляторные плагины plugin.spring и plugin.jpa, которые автоматически подключают no-arg и all-open. При создании нового Spring-проекта через мастер New Project в IntelliJ IDEA или на сайте start.spring.io оба плагина настраиваются автоматически. Начиная с IntelliJ IDEA 2026.1 это также будет происходить при добавлении Kotlin-файла в уже существующий Java-проект.

plugins {
   kotlin("plugin.spring") version "2.2.20"
   kotlin("plugin.jpa") version "2.2.20"
}

allOpen {
   annotation("jakarta.persistence.Entity")
   annotation("jakarta.persistence.MappedSuperclass")
   annotation("jakarta.persistence.Embeddable")
}

При ручной настройке обратите особое внимание на обе части конфигурации. Плагин plugin.jpa выглядит так, будто он предоставляет всю необходимую настройку, однако на самом деле он конфигурирует только плагин no-arg, но не all-open.

Это будет улучшено в предстоящем обновлении JPA-плагина — после него больше не придётся вручную добавлять секцию allOpen.
См. задачу: KT-79389.

Изменяемость

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

var против val


В Kotlin ключевое слово val используется для объявления неизменяемого поля или свойства, а var — для изменяемого. На уровне байткода val компилируется в Java как final-поле. Но, как уже обсуждалось, спецификация Jakarta Persistence требует, чтобы все поля сущностей были не final.

Комментарий от Михаила Поливаха

На деле val это не field, а property. Это два разных понятия с точки зрения дизайна ЯП.

Property можно воспринимать как опциональный field + acccessor-ы к нему (getter + setter). Поэтому далеко не всегда val имеет т.н. backing field. Довольно хорошо это расписано в данной секции документации ЯП Kotlin: https://kotlinlang.org/docs/properties.html#backing-fields

Таким образом, теоретически вы не можете использовать val при моделировании сущностей. Однако, если взглянуть на реальные проекты, можно найти немало классов-сущностей, в которых используется val, и при этом это не приводит к ошибкам.

@Entity
class Person(name: String) {
   @Id
   @GeneratedValue
   var id: Long? = null

   val name: String = name
}

Это объясняется тем, что реализация Jakarta Persistence — то есть используемый провайдер JPA — заполняет поля сущности с помощью рефлексии, если вы используете доступ к полям (field-based access), что обычно и происходит при реализации сущностей на Kotlin. Поля, помеченные как final, также могут быть изменены через рефлексию. В результате провайдер может изменять поля, объявленные с val, несмотря на то, что Kotlin рассматривает их как неизменяемые.

Комментарий от Михаила Поливаха

Как мы писали ранее, скоро изменять final поля в Java будет нельзя без явного opt-in. Это общее движение Java Platform-ы в рамках Integrity By Default (целостность по-умолчанию). Другие задачи в рамках этого направления – это постепенный вывод Unsafe из эксплуатации, запрет вызов сторонних JNI методов без явного opt-in и т.д.

Таким образом, на практике вы можете использовать val для моделирования неизменяемых полей в классе сущности. Однако это не соответствует требованиям спецификации Jakarta Persistence, и такие поля на самом деле не настолько неизменяемы, как вам может казаться. Более того, инициатива JEP 500: Prepare to Make Final Mean Final предполагает внедрение предупреждений и будущих изменений, ограничивающих возможность модификации final-полей через рефлексию. Это приведёт к тому, что использование val в сущностях станет невозможным и нарушит работу многих persistence-слоёв, основанных на Jakarta Persistence и Kotlin.

Будьте осторожны при использовании val в сущностях и убедитесь, что вся команда осознаёт возможные последствия.

Начиная с версии 2026.1, IntelliJ IDEA будет показывать слабое предупреждение о том, что значение поля, объявленного с val, будет изменено провайдером JPA (например, Hibernate или EclipseLink) при создании экземпляра сущности.

Типы доступа

Спецификация Jakarta Persistence определяет два типа доступа, от которых зависит, будет ли провайдер JPA обращаться к полям сущности напрямую через рефлексию или через методы getter/setter.

Вы можете явно указать тип доступа, аннотируя класс сущности аннотацией @Access. Или — как делает почти каждая команда разработки — задать его неявно, в зависимости от того, где размещены аннотации маппинга:

  • Аннотации на полях сущностидоступ к полям = прямой доступ через рефлексию

  • Аннотации на геттерахдоступ к свойствам = доступ через методы getter/setter

Большинство разработчиков на Kotlin размещают аннотации на свойствах, что Hibernate по умолчанию интерпретирует как доступ к полям.

Комментарий от Михаила Поливаха

Тут на самом деле автор не совсем прав. Сказат��, какой конкретно use-site target будет у аннотации в Kotlin можно, зная только версию языка. Например, начиная с Kotlin 2.2 дефолтная политика в этом плане поменялась, смотрите соотвествующий KEEP.

@Entity
class Company {
   @Id
   @GeneratedValue
   var id: Long? = null

   var name: String? = null
       get() {
           println("Getter called")
           return field
       }
       set(value) {
           println("Setter called")
           field = value
       }
}

В этом примере может показаться, что для доступа к свойству name будут вызываться методы getter и setter. Однако это справедливо только для вашей бизнес-логики. Поскольку аннотации размещены на полях, провайдер JPA будет обращаться к ним напрямую через рефлексию, минуя геттеры и сеттеры.

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

Если вы хотите использовать доступ к свойствам, вы можете либо аннотировать класс сущности с помощью @Access(AccessType.PROPERTY), либо явно указать аннотации на геттерах и сеттерах:

@Entity
class Company {
   @get:Id
   @get:GeneratedValue
   var id: Long? = null

   var name: String? = null
       get() {
           println("Getter called")
           return field
       }
       set(value) {
           println("Setter called")
           field = value
       }
}

Однако при таком подходе необходимо убедиться, что все поля объявлены с использованием var. Kotlin не генерирует методы setter для полей, объявленных как val.

@Entity
class Company {
   @get:Id
   @get:GeneratedValue
   var id: Long? = null

   val name: String? = null 
}

Это становится очевидным, если посмотреть на декомпилированный байткод Kotlin для приведённого выше примера.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import kotlin.Metadata;
import org.jetbrains.annotations.Nullable;


@Entity
…
public final class Company {
  @Nullable
  private Long id;
  @Nullable
  private final String name;

  @Id
  @GeneratedValue
  @Nullable
  public final Long getId() {
     return this.id;
  }

  public final void setId(@Nullable Long var1) {
     this.id = var1;
  }

  @Nullable
  public final String getName() {
     return this.name;
  }
}

Провайдер JPA проверит, что для каждого поля доступны методы getter и setter. Поэтому, если вы используете var для определения полей сущности, доступ к свойствам будет корректно работать с Kotlin.

Безопасность при работе с null и значения по умолчанию

Безопасность при работе с null и значения по умолчанию — две популярные особенности Kotlin, которых в таком виде нет в Java. Поэтому неудивительно, что при использовании этих возможностей в сущностях Jakarta Persistence требуется особое внимание.

Обработка null-значений (включая поля первичного ключа)

Kotlin позволяет явно указывать, допускает ли поле или свойство значение null. К сожалению, механизм рефлексии способен обойти защиту от null, встроенную в Kotlin. А как уже говорилось ранее, провайдер JPA использует рефлексию для инициализации объектов сущностей.

Даже если вы объявили атрибут сущности как не допускающий null, провайдер может присвоить ему значение null, если в базе данных оно действительно присутствует. В бизнес-логике это может привести к ошибкам времени выполнения, аналогичным тем, что возникают в Java.

@Entity
@Table(name = "user")
class User(
   @Id
   var id: Long? = null

   var name: String
)

fun testLogic(){
   // Suppose the row with id = 1 has name = NULL in the database
   val user = userRepository.findById(1).get()
   println("Firstname: ${user.name}") // null, because Hibernate saves null via reflection
}

И, к сожалению, решить эту проблему не так просто, как может показаться.

Можно утверждать, что все не допускающие null поля сущности должны отображаться на колонки базы данных с ограничением NOT NULL. В таком случае в базе просто не может быть null-значений.

В целом, это отличный подход. Но он не устраняет риск полностью. Ограничения целостности могут расходиться между средами или нарушаться в процессе миграций. Поэтому использование ограничений NOT NULL на уровне базы данных настоятельно рекомендуется, но оно не даёт абсолютной гарантии, что вы никогда не получите null при загрузке данных.

Более того, все реализации Jakarta Persistence вызывают конструктор без аргументов для создания объекта сущности, а затем инициализируют каждое поле через рефлексию. Это означает, что с технической точки зрения все поля вашей сущности должны допускать значение null.

Что это значит для ваших сущностей? Следует ли использовать val или var при моделировании полей?

В конечном счёте, решение остаётся за вами. Оба варианта будут работать. Мы рекомендуем придерживаться идиоматики Kotlin: используйте val, если поле сущности не должно изменяться бизнес-логикой, и var — в противном случае. Однако, с учётом описанных выше особенностей, важно, чтобы все члены вашей команды осознавали: реализация Jakarta Persistence может присвоить таким полям значение null, если в базе отсутствует ограничение NOT NULL.

@Id и автогенерация значений

В предыдущих разделах уже обсуждалось, почему все поля сущности следует объявлять как допускающие null. Однако многие разработчики считают, что атрибуты первичного ключа — это особый случай, ведь база данных требует, чтобы у каждой записи был первичный ключ, а спецификация Jakarta Persistence определяет его как неизменяемый. Действительно, после сохранения объекта сущности в базу данных, первичный ключ становится обязательным и неизменным. Но давайте разберёмся, почему это не значит, что поле первичного ключа должно быть объявлено как not-nullable — особенно если вы используете автоматическую генерацию значений.

Когда вы сохраняете новую запись в базу данных, вы создаёте объект сущности без первичного ключа, а затем вызываете persist.

К сожалению, спецификация Jakarta Persistence не даёт чёткого определения того, как именно должна работать операция persist. Однако она требует, чтобы значение первичного ключа было сгенерировано, если оно не указано вручную. Реализации JPA по-разному обрабатывают ситуации, когда значение первичного ключа уже задано, но это тема для отдельного разговора.

Главное здесь то, что все провайдеры JPA трактуют значение null как отсутствие заданного первичного ключа. В таком случае они используют последовательности базы данных (sequence) или автоинкрементируемые колонки для генерации значения первичного ключа, которое затем устанавливается в объекте сущности.

Из-за этого механизма значение первичного ключа изначально — null, а затем изменяется в момент сохранения объекта в базу данных.

Интересный момент заключается в том, что Hibernate по-разному обрабатывает значение первичного ключа, равное 0, при вызове методов persist и merge.

Метод persist выбрасывает исключение, поскольку считает, что объект с первичным ключом 0 уже сохранён в базе данных. В то же время метод merge в Hibernate интерпретирует это как необходимость создать новую запись: он генерирует новое значение первичного ключа и вставляет новую строку в базу.

Именно поэтому вы можете смоделировать первичный ключ с значением по умолчанию 0 и сохранить новый объект сущности с помощью Spring Data JPA. Реализация репозитория по умолчанию обнаруживает, что первичный ключ уже установлен, и вызывает метод merge вместо persist.

Теперь вернёмся к инициализации полей первичного ключа.

Когда вы извлекаете объект сущности из базы данных, провайдер JPA использует конструктор без аргументов для создания нового объекта. Затем с помощью рефлексии он устанавливает значение первичного ключа перед тем, как вернуть объект в бизнес-логику.

Всё это ясно показывает, что спецификация Jakarta Persistence ожидает, что поле первичного ключа будет изменяемым, даже несмотря на то, что значение ключа не должно изменяться после назначения.

Чтобы избежать проблем с переносимостью между разными реализациями Jakarta Persistence, используйте null для обозначения неопределённого значения первичного ключа.

@Entity
class Company {
   @Id
   @GeneratedValue
   var id: Long? = null
}

Объявление значений по умолчанию

Поддержка значений по умолчанию в Kotlin может упростить бизнес-логику и предотвратить появление null-значений.

@Entity
class Company(
   @Id @GeneratedValue 
   var id: Long? = null,

   @NotNull
   var name: String = "John Doe",

   @Email
   var email: String = "default@email.com"
)

Однако имейте в виду, что эти значения по умолчанию не будут иметь никакого эффекта, когда провайдер JPA загружает объект сущности из базы данных.

val companyFromDb = companyRepository.findById(1).get()
println(companyFromDb.email) // <- If email in DB is empty, it will not set to "default@email.com"

Спецификация Jakarta Persistence требует наличия конструктора без параметров, который реализации JPA вызывают при загрузке объекта сущности из базы данных. После этого они используют рефлексию для присвоения полям объекта значений, полученных из базы данных.

В результате значения по умолчанию, указанные в вашем конструкторе, не будут использоваться, и некоторые поля объекта сущности могут остаться неинициализированными, даже если вы ожидаете, что конструктор задаст им значения по умолчанию.

Это может не вызывать проблем в работе приложения, но вы и ваша команда должны чётко понимать, как именно работает этот механизм.

Размещение аннотаций

В Java аннотации, как правило, применяются напрямую к полю, методу или классу. В Kotlin, напротив, аннотации могут быть направлены на различные элементы, такие как параметры конструктора, свойства или поля.

До версии Kotlin 2.2 это часто вызывало проблемы, поскольку аннотации, применённые к свойствам, объявляемых в конструкторе, по умолчанию применялись только к параметру конструктора. Это вызывало затруднения при работе с Jakarta Persistence и фреймворками валидации: такие аннотации, как @NotNull, @Email или @Id, в итоге оказывались не в том месте, где их ожидал фреймворк. В результате могли не сработать проверки или возникнуть ошибки отображения (маппинга).

Хорошая новость заключается в том, что начиная с Kotlin 2.2 ситуация улучшилась. Благодаря новой опции компилятора, которую IntelliJ IDEA предложит включить автоматически, аннотации теперь по умолчанию применяются и к параметру конструктора, и к свойству или полю. Таким образом, ваш код теперь будет работать как ожидается, без необходимости что-либо менять вручную.

Подробнее об этом вы можете узнать в соответствующей публикации в блоге.

IntelliJ IDEA придёт на помощь!

В предстоящем релизе IntelliJ IDEA 2026.1 вас ждут новые инспекции и автоматические исправления, которые помогут справиться со многими проблемами, описанными в этой статье, и сделают работу с проектами на Kotlin и JPA ещё комфортнее. Обязательно обновитесь, когда выйдет новая версия. Вот лишь некоторые из улучшений, которые будут доступны:

  • Подсветка отсутствующих конструкторов без аргументов или final-классов сущностей с подсказками по подключению нужных Kotlin-плагинов.

  • Автоматическая настройка всех необходимых параметров при добавлении Kotlin в проект.

  • Обнаружение и быстрое исправление использования data-классов и свойств val в JPA-сущностях.

  • А также другие улучшения, связанные с поддержкой JPA!

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.