This guide covers Spring Data JPA with Kotlin for Spring Boot 4.x projects — from JPA in Spring Boot setup and entity modeling to Spring Data JPA repositories, projections, pagination, and dynamic filtering. Every Spring Boot JPA example uses idiomatic Kotlin.
Spring Boot is the framework layer that lets you build production-ready apps quickly, with auto-configuration, an embedded server, sensible defaults, and fewer manual setup steps.
Spring Data JPA is the persistence layer that sits on top of JPA/Hibernate and provides repository APIs for common database tasks (CRUD, pagination, query methods) without requiring you to write boilerplate DAO code.
Kotlin improves this workflow by making backend code shorter and safer:
- Null-safety reduces a large class of runtime null errors.
- Data/DTO modeling is concise and readable.
- Extension functions and expressive syntax keep service/repository code easier to maintain.
Table of contents
- Start your Kotlin project with Spring Initializr
- JPA Entity Basics in Kotlin: @Entity, @Id, @Column
- @Entity
- @Id
- @EmbeddedId
- @Column(…) (optional)
- No-arg constructor requirement
- Mapping Domain States with Enums
- Overriding equals and hashCode in JPA Entities
- Best Practices for Designing JPA Entities
- Can a Kotlin data class be a JPA entity?
- Accessing Data with JpaRepository and CrudRepository
- JPQL and @Query: Querying Data with Spring Data JPA
- Updating data in repositories
- Kotlin-Idiomatic Entity Fetching with Spring Data JPA
- Pagination in Spring Boot: Pageable, Page, and Slice
- Page vs Slice vs List
- Pageable and PageRequest
- Sort
- Type-safe sort with JpaSort and TypedSort
- Repository method signatures
- Count query optimization
- Common pitfalls wigh Spring Data JPA pagination and sorting
- Dynamic Filtering with Spring Data JPA Specifications
- JpaSpecificationExecutor
- JPA static metamodel
- QueryDSL with Spring Data JPA
- Spring Data JPA Projections in Kotlin
- Interface-based projection (read-only view)
- Class/DTO projection with Spring Data JPA
- Dynamic projection (choose shape at call site)
- DTO mapping approaches beyond projections
- At the end
Start your Kotlin project with Spring Initializr
For beginners, Spring Initializr is the fastest and safest way to create a correct project skeleton.
- Open start.spring.io.
- Choose:
-
Project: Gradle (Kotlin) -
Language: Kotlin -
Spring Boot: latest stable 4.x -
Group: for exampledev.protsenko -
Artifact: for exampledemo
- Add dependencies:
Spring WebSpring Data JPA- one database driver (
H2for local learning, orPostgreSQL Driverfor real DB usage)
- Generate and unzip the project.
- Open it in IntelliJ IDEA and run the main application class.
Recommendation: use Spring Initializr for your first projects and most new services. It prevents common setup mistakes and gives you a clean baseline to start coding features immediately.
At this point, your project should be running, so let’s move to the core Spring Data JPA concept: entities.
JPA Entity Basics in Kotlin: @entity, @id, @column
A JPA entity is a Kotlin class that JPA maps to a database table. One entity instance usually represents one row. Fields map to columns, and associations (@OneToMany, @ManyToOne, etc.) map to relationships.
At minimum, an entity needs @Entity and a primary key definition (@Id or @EmbeddedId). In practice, JPA annotations split into three layers: class-level mapping (@Entity), key mapping (@Id or @EmbeddedId), and optional field-level tuning (@Column when defaults are not enough). The next subchapters explain each layer.
@Entity
- For what: marks a Kotlin class as a JPA entity (persistent model).
- Arguments: main argument is
name, used by JPQL (@Entity(name = "...")). - When to use: always on persistent classes; set
nameexplicitly when you use JPQL strings and want safer class refactors.
@Id
- For what: defines a single-column primary key.
- Arguments:
@Iditself has no arguments; it is commonly paired with@GeneratedValue(strategy = ...). - When to use: when your table key is one column (for example
idUUID, Long, Integer).
@EmbeddedId
- For what: defines a composite primary key via an embeddable key object.
- Arguments:
@EmbeddedIdhas no arguments; key fields are defined in the embeddable class. - When to use: when the table primary key is naturally multi-column.
@Column(...) (optional)
- For what: customizes field-to-column mapping.
- Arguments and how they work:
-
name: explicit DB column name. Use when DB naming differs from your Kotlin property name. -
nullable: whether the column can storeNULL. Keep this aligned with Kotlin type nullability. When Hibernate generates DDL it uses this value to emitNOT NULL; when you manage schema with a migration tool (Liquibase, Flyway), the constraint lives in the migration script –@Column(nullable = false)still communicates intent to the ORM, but the actualNOT NULLin the database comes from your migration. -
length: max length for string columns (for exampleVARCHAR). Use for bounded text fields. -
unique: shorthand single-column unique constraint. Use sparingly; prefer DB migrations for complex unique indexes. -
precision: total number of digits for decimal columns (usually withBigDecimal). -
scale: number of digits after decimal point (usually withBigDecimal). - When to use: when default mapping is not enough. For minimal entities, you can omit
@Column. - Kotlin nullability rule:
- Prefer non-null Kotlin types by default (
String,Int,UUID) for required data. - Use nullable Kotlin types (
String?,Int?,UUID?) only when the value is truly optional in business logic. - Keep type and column settings consistent: non-null type with
nullable = false, nullable type withnullable = true. - Important: a non-null Kotlin type alone does not create a
NOT NULLconstraint in the database. Add@Column(nullable = false)on required fields to communicate nullability intent to the ORM. The actual constraint in the database depends on how you manage schema: Hibernate-generated DDL will use this annotation, but in production environments schema is typically managed by a migration tool (Liquibase, Flyway) – defineNOT NULLin the migration script and keep it in sync with the entity annotation.
No-arg constructor requirement
- For what: JPA needs a no-arg constructor for entity instantiation.
- Arguments: not an annotation itself in this context.
- When to use: always required; in Kotlin projects this is typically handled by the Kotlin JPA plugin (
kotlin("plugin.jpa")), which generates synthetic no-arg constructors for@Entity-annotated classes at compile time. - Non-final requirement: Jakarta Persistence also requires entity classes and their persistent members to be non-final. Kotlin classes are final by default, so Kotlin projects add
kotlin("plugin.allopen")(or the Spring Boot equivalentkotlin("plugin.spring")combined withallOpenJPA annotation configuration) to make JPA-annotated classes open automatically.
Mapping Domain States with Enums
Enums are the safest way to represent different states (status, type, role, mode) in Spring Data JPA.
- For what: map business states to a closed set of values instead of magic numbers or free-text strings.
- How it works: use
@Enumerated(EnumType.STRING)on the enum field. - When to use: default choice for state columns. Avoid
EnumType.ORDINALbecause reordering enum constants can silently change data meaning.
If your existing schema stores numeric codes (for example 1, 2, 3), use an AttributeConverter instead of EnumType.ORDINAL.
@Converter class CompanyStatusCodeConverter : AttributeConverter<CompanyStatus, Int> { override fun convertToDatabaseColumn(attribute: CompanyStatus) = when (attribute) { CompanyStatus.ACTIVE -> 1 CompanyStatus.SUSPENDED -> 2 CompanyStatus.DISSOLVED -> 3 } override fun convertToEntityAttribute(dbData: Int?) = when (dbData) { 1 -> CompanyStatus.ACTIVE 2 -> CompanyStatus.SUSPENDED 3 -> CompanyStatus.DISSOLVED else -> throw IllegalArgumentException("Unknown status code: $dbData") } } Two rules for writing a safe converter:
-
convertToDatabaseColumnreceives the Kotlin type – match its nullability to the entity field. If the field is non-nullable (CompanyStatus), the parameter is non-nullable too – nonullbranch needed. -
convertToEntityAttributemust never silently returnnullfor unknown DB values when the entity field is non-nullable. Throw instead – a corrupt database value is a programming error, not a recoverable state.
Use it on the entity field:
@Convert(converter = CompanyStatusCodeConverter::class) var status: CompanyStatus = CompanyStatus.ACTIVE Example: let’s map a company table to an entity with id (UUID primary key), name, countryCode, status (enum), and optional description.
enum class CompanyStatus { ACTIVE, SUSPENDED, DISSOLVED } @Entity(name = "Company") @Table(name = "company") class Company( val name: String, val countryCode: String, @Enumerated(EnumType.STRING) var status: CompanyStatus = CompanyStatus.ACTIVE, var description: String? = null ) { @Id @GeneratedValue(strategy = GenerationType.UUID) var id: UUID? = null } Overriding equals and hashCode in JPA Entities
Overriding equals and hashCode in JPA entities is context-dependent. Many applications work fine with defaults from Any/Object.
Context details:
- In a typical Spring Boot
spring-boot-starter-data-jpasetup, Hibernate is the JPA provider (ORM library) brought in by the starter. - A persistence context is Hibernate’s first-level cache bound to the current transaction/session. Inside that scope, one DB row is represented by one managed object instance.
- Because of that identity map, reference equality is often enough while you stay in one transaction/session.
Why dynamic/mutable equals/hashCode is dangerous:
- If equality depends on mutable fields, object hash can change after insertion into
Set/Map, which breaks lookup/remove behavior. - If equality touches lazy associations,
equals/hashCode/toStringcan trigger extra SQL queries unexpectedly (N+1 side effects). - If equality traverses relationships, it can recurse deeply, increase CPU/memory usage, and even cause stack overflows in cyclic graphs.
- In practice, these patterns create correctness bugs first, and performance problems second.
Custom equality is usually needed only when:
- entities are keys in
Map, - entities are stored in
Set, - entities are compared across persistence contexts.
Main strategies:
- Default implementation from
Any/Object(recommended baseline). This is reference-based equality, which works well when entities stay inside one persistence context and are not used as keys in hashed collections. - Business-key equality (recommended only if the key is unique and immutable).
- ID-based equality (common with generated IDs, but requires careful handling of transient entities with
id == null). - All-fields equality is unsafe for JPA entities (mutable fields, lazy associations, recursion risk, and unstable hashes).
Best Practices for Designing JPA Entities
Field typing practices for entities:
- Match Kotlin nullability to DB nullability:
Stringfor required values,String?only for truly optional data. Annotate required fields with@Column(nullable = false)to communicate nullability intent to the ORM. In production, the actualNOT NULLconstraint is typically defined in a migration tool (Liquibase, Flyway) – keep the entity annotation and migration script in sync. - Prefer domain-safe types over primitives where meaning matters:
BigDecimalfor money,Instant/OffsetDateTimefor timestamps, enums for finite states. - Reuse embeddable value objects (
@Embeddable) for repeated concepts (for example, address, amount+currency) instead of duplicating scalar fields across entities. - Avoid fake placeholder values (empty string,
0,-1) for “missing” data. When a value is truly optional, represent it explicitly as a nullable DB column + nullable Kotlin type (String?,Int?) or model it as an enum state likeUNKNOWN/NOT_PROVIDED. - Avoid putting heavy business logic in entity constructors; keep constructor requirements minimal for ORM lifecycle.
- Avoid
data classfor entities because generatedequals/hashCode/toString/copyconflict with JPA lifecycle and lazy associations.
Can a Kotlin data class be a JPA entity?
It compiles, and Hibernate will even save it. That’s the trap. A Kotlin data class generates equals, hashCode, toString and copy from every property in the primary constructor, and on an entity those properties include the generated id and the associations.
@Entity data class Author( @Id @GeneratedValue(strategy = GenerationType.UUID) var id: UUID? = null, var name: String = "", @OneToMany(mappedBy = "author", cascade = [CascadeType.ALL]) val books: MutableList<Book> = mutableListOf(), ) @Entity data class Book( @Id @GeneratedValue(strategy = GenerationType.UUID) var id: UUID? = null, var title: String = "", @ManyToOne(fetch = FetchType.LAZY) var author: Author? = null, ) I reproduced each of these problems with a test against a real Hibernate session:
- The entity disappears from a
HashSet. Put a newBookinto a set, persist it, and Hibernate fills inid. The hash code changes with it, soset.contains(book)returnsfalsefor the same object. -
hashCodenever ends.Author.hashCode()hashesbooks, eachBookhashes itsauthor, and around it goes untilStackOverflowError.toStringandequalshave the same loop on any bidirectional association. -
toStringtouches lazy data. The generatedtoStringreadsauthor, which is a lazy proxy. Call it after the session is closed (a log line after the service returned) and it throwsLazyInitializationException. -
copy()looks like the entity, but it isn’t managed. The copy isequalto the managedBook, same id and all, yet Hibernate doesn’t track it. Change its title inside the transaction and the change is never saved.
Use a regular class for entities, keep the id out of the constructor, and write toString by hand without associations:
@Entity class Book( var title: String, @ManyToOne(fetch = FetchType.LAZY) var author: Author? = null, ) { @Id @GeneratedValue(strategy = GenerationType.UUID) var id: UUID? = null override fun toString() = "Book(id=$id, title=$title)" } Data classes are still the right tool one layer up: DTOs and class-based projections (see the projection chapter below), where value equality is what you want.
What a data class compiles to, and where it breaks JPA, Jackson and validation in Spring Boot 4, is in Kotlin data class in Spring Boot 4: what the bytecode shows.
Accessing Data with JpaRepository and CrudRepository
To access the data you write only a repository interface, and Spring Data JPA creates the real implementation class automatically when the app starts. So the parent interface you choose (CrudRepository, JpaRepository, etc.) directly decides what methods your repository has.
Typical flow:
- Define
CompanyRepositoryinterface. - Inject it into a service.
- Call methods like
save,findById, or custom query methods.
-
Repository<T, ID>: marker-only base; you expose only methods you declare. -
CrudRepository<T, ID>: adds standard CRUD methods. -
ListCrudRepository<T, ID>: same asCrudRepository, but list-returning methods useList. -
PagingAndSortingRepository<T, ID>: adds pagination and sorting. -
JpaRepository<T, ID>: most common default; includes CRUD + pagination + sorting + JPA-specific helpers (flush, batch operations). -
JpaSpecificationExecutor<T>(optional companion interface): dynamic predicate queries via Specifications. -
QueryByExampleExecutor<T>(optional companion interface): probe-based querying without explicit JPQL.
Typical Kotlin repository:
interface CompanyRepository : JpaRepository<Company, UUID> When to choose what:
- If you need strict API boundaries, extend
Repository(or a slim shared base) and expose only allowed operations. You can also use@NoRepositoryBeanon abstract base repository interfaces. - If you need a convenient full-CRUD repository, extend
JpaRepository.
Small note: @Repository is not required on Spring Data repository interfaces. When an interface extends Spring Data repository contracts (Repository, CrudRepository, JpaRepository, etc.), Spring auto-detects it and registers a proxy bean automatically.
JPQL and @Query: Querying Data with Spring Data JPA
If you need data access beyond default CRUD methods, Spring Data JPA provides three mainstream query styles:
- Derived query methods (query generated from method name).
- JPQL queries via
@Query. - Native SQL queries via
@Query(nativeQuery = true)or@NativeQuery(introduced in newer Spring Data versions).
What “derived” means:
- Spring parses method names like
findByCountryCodeAndStatusand builds JPQL automatically. - This is best for straightforward filters and keeps repository code concise.
Derived method example with Company:
fun findByCountryCodeAndStatus(countryCode: String, status: CompanyStatus): List<Company> Explicit JPQL query with Company:
@Query( "select c from Company c where c.countryCode = :countryCode and c.status = :status" ) fun findActiveInCountry( @Param("countryCode") countryCode: String, @Param("status") status: CompanyStatus ): List<Company> Updating data in repositories
For update/delete JPQL queries, both annotations are important:
-
@Modifyingtells Spring Data this query changes data (not a select). -
@Transactionalprovides transaction boundaries so the change is committed atomically.
@Modifying(clearAutomatically = true, flushAutomatically = true) @Transactional @Query("update Company c set c.status = :status where c.id = :id") fun updateStatus( @Param("id") id: UUID, @Param("status") status: CompanyStatus ): Int Bulk JPQL update/delete queries run directly in the database and bypass the normal entity lifecycle in the current persistence context. In practice, this means managed entities that were already loaded (for example, a Company fetched earlier in the same transaction) are not automatically synchronized with the bulk result, so your code can keep reading stale in-memory values. That stale state can cause incorrect business decisions, confusing test behavior, and accidental overwrite patterns later in the same transaction. The safe default is to flush pending changes before the bulk query and clear the persistence context after it; in Spring Data JPA, @Modifying(clearAutomatically = true, flushAutomatically = true) is a practical way to enforce that for repository bulk methods.
Kotlin-Idiomatic Entity Fetching with Spring Data JPA
Spring Data’s Java APIs commonly use Optional<T> for missing individual CRUD results, while query methods can also model absence through null or wrapper return types. In Kotlin, Optional<T> is unnecessary: the type system already expresses presence and absence through nullable types (T vs T?). Spring Data provides native ways to work with this directly.
// You could specify nullable values in repositories fun findByName(name: String): Company? @Query("select c from Company c where c.name = :name") fun findOneByName(@Param("name") name: String): Company? // Remember: non-nullable return values will raise exception in runtime fun getByName(name: String): Company @Query("select c from Company c where c.name = :name") fun getOneByName(@Param("name") name: String): Company Prefer nullable return types (Company?) as the default. A non-nullable return type (Company) throws EmptyResultDataAccessException at runtime when no result is found – this is a runtime error the compiler cannot warn you about, making it easy to miss.
Another idiomatic option is findByIdOrNull, a Spring Data extension function that returns T? directly:
val company: Company? = companyRepository.findByIdOrNull(id) The import is required – findByIdOrNull is a top-level extension function on CrudRepository and will not resolve without it.
Pagination in Spring Boot: Pageable, Page, and Slice
Most applications need to return data in pages rather than all at once. Spring Data JPA provides three result types for paginated queries – choose based on what the caller actually needs.
Page vs Slice vs List
Page<T> | Slice<T> | List<T> | |
|---|---|---|---|
| COUNT query | Yes | No | No |
| Knows total records | Yes | No | No |
hasNext() mechanism | number + 1 < totalPages | fetches N+1 rows | n/a |
| Use case | UI with page numbers, total count in API | infinite scroll, “load more” | all results, caller controls |
Page<T> fires a SELECT count(*) query when necessary so the caller knows the total number of records. Use it when you need to render “Page 3 of 47” or expose totalElements in an API response.
Slice<T> skips the count query entirely. Spring Data fetches pageSize + 1 rows – if more than pageSize rows come back, hasNext() returns true and the extra row is discarded. Use it for infinite scroll or “load more” UIs where the total is irrelevant.
List<T> with Pageable returns only the content with no metadata at all. Use it when you need a window of results but don’t need navigation information.
Pageable and PageRequest
Pageable is the abstraction that carries which page to retrieve and how to sort it. For paged requests, construct it via PageRequest:
// Page 0, 20 items per page, no sort val page = PageRequest.of(0, 20) // With sort val sorted = PageRequest.of(0, 20, Sort.by("name")) val desc = PageRequest.of(0, 20, Sort.Direction.DESC, "status") // Shortcut for page 0 val first10 = PageRequest.ofSize(10) Page numbers are zero-based: page 0 is the first page.
PageRequest is immutable. Use the wither methods to derive a new instance:
val next = page.next() // page=1, same size and sort val specific = page.withPage(5) // page=5, same size and sort val resort = page.withSort(Sort.by("status")) To fetch all records without pagination, use Pageable.unpaged(). A repository method that accepts Pageable and returns Page<T> will return all results with totalElements == content.size when passed an unpaged request:
val all = companyRepository.findAll(Pageable.unpaged()) Sort
Sort is immutable and composed of one or more Sort.Order instances. The simplest form:
Sort.by("name") // ASC by default Sort.by(Sort.Direction.DESC, "name") Sort.by(Sort.Order.desc("status"), Sort.Order.asc("name")) Sort.Order provides fine-grained control:
Sort.Order.asc("name").ignoreCase() // case-insensitive sort Sort.Order.asc("description").nullsLast() // NULLs sorted after non-nulls Sort.Order.desc("description").nullsFirst() // NULLs sorted before non-nulls Multiple fields:
val sort = Sort.by( Sort.Order.desc("status"), Sort.Order.asc("name").ignoreCase(), Sort.Order.asc("description").nullsLast() ) Combine existing Sort instances:
val combined = Sort.by("name").and(Sort.by(Sort.Direction.DESC, "status")) val reversed = Sort.by("name").reverse() Type-safe sort with JpaSort and TypedSort
Plain Sort.by("name") is a string – a typo compiles but throws at runtime. Two type-safe alternatives exist.
JpaSort uses the JPA static metamodel attributes (Company_) generated by Hibernate:
val sort = JpaSort.of(Sort.Direction.ASC, Company_.name) val multi = JpaSort.of(Company_.name).and(JpaSort.of(Sort.Direction.DESC, Company_.status)) If Company_ doesn’t have the field, the code doesn’t compile.
Sort.TypedSort<T> uses method references on the entity class:
val typedSort = Sort.sort(Company::class.java) val byName = typedSort.by(Company::name).ascending() val byStatus = typedSort.by(Company::status).descending() TypedSort works only on open classes – your allOpen<...
