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
- Accessing Data with JpaRepository and CrudRepository
- Pagination in Spring Boot: Pageable, Page, and Slice
- Dynamic Filtering with Spring Data JPA Specifications
- Spring Data JPA Projections in Kotlin
- 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: KotlinSpring Boot: latest stable 4.xGroup: for exampledev.protsenkoArtifact: 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.ACTIVEExample: 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.
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
): IntBulk 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): CompanyPrefer 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-nullsMultiple 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 plugin configuration already handles this for @Entity classes.
Repository method signatures
Declare pagination support by adding a Pageable parameter and choosing the return type:
interface CompanyRepository : JpaRepository<Company, UUID> {
// Page<T> - fires count query, returns total metadata
fun findByCountryCode(countryCode: String, pageable: Pageable): Page<Company>
// Slice<T> - no count query, only hasNext
// Different method name required - Kotlin cannot overload by return type alone
fun findAllByCountryCode(countryCode: String, pageable: Pageable): Slice<Company>
// Custom countQuery - required when JPQL has joins or GROUP BY
@Query(
value = "select c from Company c where c.countryCode = :code and c.status = :status",
countQuery = "select count(c) from Company c where c.countryCode = :code and c.status = :status"
)
fun findActiveInCountryPaged(
@Param("code") countryCode: String,
@Param("status") status: CompanyStatus,
pageable: Pageable
): Page<Company>
}For queries involving JOIN, DISTINCT, or GROUP BY, auto-derived count queries frequently produce wrong results – provide an explicit countQuery. Native SQL queries always require one since Spring Data’s built-in enhancer handles only simple queries for count derivation.
Count query optimization
Spring Data will skip the COUNT query automatically when the result is a partial page (fewer rows than the requested page size). If the current page is not full, the total can be inferred without hitting the database:
- First page, partial result →
totalElements = content.size - Any other page, partial result →
totalElements = offset + content.size
The count query only fires when the page is full and the total is genuinely unknown.
Common pitfalls wigh Spring Data JPA pagination and sorting
join fetch with Page<T> – never combine JOIN FETCH on a collection association with a paginated query returning Page<T>. Hibernate cannot apply LIMIT/OFFSET at the SQL level when a collection is eagerly fetched in the same query, so it loads all matching rows into memory first and then paginates in Java. On large datasets, this causes out-of-memory errors. Hibernate logs a warning: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory.
Sorting by non-persistent fields – Sort.by("computedField") throws PropertyReferenceException at runtime if the field does not exist on the entity. Use JpaSort with metamodel attributes to catch this at compile time.
Returning PageImpl directly from controllers – PageImpl is not a stable serialization contract and its JSON shape can change between Spring Data versions. Map to an explicit DTO or configure page serialization mode explicitly:
DIRECT(default): serializesPageImpldirectly; the JSON shape includes Spring Data internals and is not guaranteed stable between versions.VIA_DTO: wraps the result in a documented, stable DTO shape – the recommended choice for API responses.
The default is DIRECT for backward compatibility. Set VIA_DTO explicitly:
spring:
data:
web:
pageable:
serialization-mode: via-dtoDynamic Filtering with Spring Data JPA Specifications
Derived query methods and @Query work well for fixed filters. When filters are optional or the combination changes at runtime – for example, a search endpoint where the user can filter by status, country, or name in any combination – you need a different approach.
JpaSpecificationExecutor
JpaSpecificationExecutor<T> is Spring Data JPA’s built-in option for dynamic filtering. Add it alongside JpaRepository in your repository interface – no extra dependencies needed:
interface CompanyRepository :
JpaRepository<Company, UUID>,
JpaSpecificationExecutor<Company>This adds findAll(spec), findAll(spec, pageable), count(spec), exists(spec), delete(spec), and update(spec).
A Specification is a lambda that receives a JPA Criteria API Root, CriteriaQuery, and CriteriaBuilder and returns a Predicate. Without the type-safe metamodel, field access uses string literals:
val spec = Specification<Company> { root, _, cb ->
cb.equal(root.get<CompanyStatus>("status"), CompanyStatus.ACTIVE) // fragile string
}A typo in "status" compiles fine but throws at runtime. The solution is the JPA static metamodel.
JPA static metamodel
Hibernate’s annotation processor generates a Company_ class from your @Entity at build time. Each field becomes a typed SingularAttribute instead of a string:
// Generated: Company_.java
@StaticMetamodel(Company.class)
@Generated("org.hibernate.processor.HibernateProcessor")
public abstract class Company_ {
public static final String NAME = "name";
public static final String COUNTRY_CODE = "countryCode";
public static final String STATUS = "status";
public static final String DESCRIPTION = "description";
public static final String ID = "id";
public static volatile EntityType<Company> class_;
public static volatile SingularAttribute<Company, String> name;
public static volatile SingularAttribute<Company, String> countryCode;
public static volatile SingularAttribute<Company, CompanyStatus> status;
public static volatile SingularAttribute<Company, String> description;
public static volatile SingularAttribute<Company, UUID> id;
}With the metamodel, field access is type-checked at compile time:
val spec = Specification<Company> { root, _, cb ->
cb.equal(root.get(Company_.status), CompanyStatus.ACTIVE) // compile-time safe
}Rename status in Company and the compiler immediately flags Company_.status.
Gradle setup (kapt – hibernate-processor version is managed by the Spring Boot BOM):
plugins {
kotlin("kapt")
}
dependencies {
kapt("org.hibernate.orm:hibernate-processor")
}
kapt {
correctErrorTypes = true
}After building, kapt generates Company_.java in build/generated/source/kapt/main/.
Specifications compose with .and() and .or(). For negation, use the static Specification.not(...) helper:
object CompanySpecs {
fun hasStatus(status: CompanyStatus) = Specification<Company> { root, _, cb ->
cb.equal(root.get(Company_.status), status)
}
fun hasCountryCode(code: String) = Specification<Company> { root, _, cb ->
cb.equal(root.get(Company_.countryCode), code)
}
}
// Usage
val result = companyRepository.findAll(
CompanySpecs.hasCountryCode("DE").and(CompanySpecs.hasStatus(CompanyStatus.ACTIVE))
)
val notSuspended = companyRepository.findAll(
Specification.not(CompanySpecs.hasStatus(CompanyStatus.SUSPENDED))
)For optional filters, returning null from a Specification lambda skips the predicate entirely – JPA treats a null predicate as no restriction:
fun hasCountryCodeOptional(code: String?) = Specification<Company> { root, _, cb ->
if (code != null) cb.equal(root.get(Company_.countryCode), code) else null
}When to use Specifications:
- You want zero extra runtime dependencies – everything is built into Spring Data JPA and Hibernate.
- You need bulk
deleteorupdateviaDeleteSpecification/UpdateSpecification. Spring Data JPA 4.0 also introducedPredicateSpecification– a query-type-agnostic variant that works across select, update, and delete queries without being tied to a single operation type. - You prefer a standardised JPA approach over a third-party query DSL.
QueryDSL with Spring Data JPA
QueryDSL generates type-safe Q-classes from your JPA entities at build time. Instead of writing query conditions as string literals (which break silently on refactoring), you reference real Kotlin properties – QCompany.company.countryCode – that fail to compile if the field is renamed or removed.
QueryDSL solves the same problem as Specifications but with a more readable fluent API: one method, any filter combination, all type-checked at compile time.
Gradle setup (kapt for Q-class generation):
plugins {
kotlin("kapt")
}
dependencies {
implementation("com.querydsl:querydsl-jpa::jakarta")
kapt("com.querydsl:querydsl-apt::jakarta")
kapt("jakarta.persistence:jakarta.persistence-api")
}The ::jakarta notation omits the version – it is managed by the Spring Boot BOM. The jakarta classifier selects the Jakarta EE 9+ variant required for Spring Boot 3.x/4.x. After building, kapt generates a QCompany class alongside your entity.
Add QuerydslPredicateExecutor<T> to your repository:
interface CompanyRepository :
JpaRepository<Company, UUID>,
QuerydslPredicateExecutor<Company>This adds findAll(predicate), findAll(predicate, pageable), count(predicate), and exists(predicate).
After building, kapt generates QCompany. Use BooleanBuilder for optional filters:
fun searchCompanies(
countryCode: String?,
status: CompanyStatus?,
pageable: Pageable
): Page<Company> {
val q = QCompany.company
val predicate = BooleanBuilder()
countryCode?.let { predicate.and(q.countryCode.eq(it)) }
status?.let { predicate.and(q.status.eq(it)) }
return companyRepository.findAll(predicate, pageable)
}An empty BooleanBuilder matches all rows – document this on filter methods so callers know no filter means no restriction.
Spring Data JPA Projections in Kotlin
Projections are specialized read models (DTOs) for data exchange with clients. Returning raw entities directly is usually a bad practice for public APIs because it leaks persistence details and often exposes more fields than needed.
Where projections fit in:
- A projection is a read-focused DTO/view shape requested directly from the database query.
- In practice, projections are often the most direct DTO path for read APIs when the query selects only required columns.
- Important nuance: DB load is reduced only if the query selects fewer columns. Fetching full entities and mapping afterward does not reduce selected columns.
A bit more about trade-offs: more DTO/projection types improve API clarity and performance, but they increase maintenance cost, so define them per use case (summary/detail/list item) instead of one mega-DTO.
Interface-based projection (read-only view)
An interface-based projection is a plain Kotlin interface where each property getter matches a field name in the entity. Spring Data JPA generates a proxy at runtime that implements the interface and maps query results onto it automatically – no constructor, no mapper, no extra class needed.
When all accessor methods map directly to entity properties, this is called a closed projection in Spring Data JPA terminology. Because Spring Data knows exactly which fields are needed, it can optimize the query and select only the projected fields – skipping any columns not declared in the interface.
Column reduction depends on how the query is written:
- Derived query methods – Spring Data automatically generates a
SELECTthat fetches only the fields declared in the interface. @Query("select c from Company c ...")– loads all columns and wraps the full entity in a proxy. No column reduction happens.@Querywith explicit field selection – fetches only the listed fields. This is the correct way to get column reduction with a custom query.
interface CompanySummaryView {
val id: UUID
val name: String
val countryCode: String
val status: CompanyStatus
}
// Derived - Spring Data selects only the four declared fields
fun findByCountryCode(countryCode: String): List<CompanySummaryView>
// @Query with explicit fields - also reduces columns
@Query("""
select c.id as id, c.name as name, c.countryCode as countryCode, c.status as status
from Company c where c.countryCode = :countryCode
""")
fun findSummaryByCountryCode(@Param("countryCode") countryCode: String): List<CompanySummaryView>Kotlin note: you cannot declare two methods with the same name and same parameters differing only by return type. If findByCountryCode(countryCode: String): List<Company> already exists, you need a distinct method name for the projection overload – as shown with findSummaryByCountryCode above.
You can also transform or combine columns in the query and map the result to the interface by matching the alias name:
interface CompanyListView {
val id: UUID
val displayName: String
}
@Query("select c.id as id, concat(c.name, ' (', c.countryCode, ')') as displayName from Company c")
fun findAllForList(): List<CompanyListView>When to use:
- Simple read-only list and summary API responses where all fields map directly to entity properties or returned from database.
- When you want minimal boilerplate – no DTO class, no constructor, no mapper required.
Class/DTO projection with Spring Data JPA
A class-based projection is a regular Kotlin data class used as the query result type directly. Instead of returning entities and mapping them afterward, you instruct JPQL to construct the DTO inline using the new keyword with a fully qualified class name. Class-based projections use JPA’s constructor expression mechanism to create DTO instances – no interface proxy is created. The response model is completely decoupled from the entity.
data class CompanySummaryDto(
val id: UUID,
val name: String,
val countryCode: String,
val status: CompanyStatus
)@Query(
"""
select new dev.protsenko.jpa.CompanySummaryDto(c.id, c.name, c.countryCode, c.status)
from Company c
where c.countryCode = :countryCode
"""
)
fun findCompanySummaries(@Param("countryCode") countryCode: String): List<CompanySummaryDto>Trade-offs:
- The fully qualified class name in the JPQL string (
new dev.protsenko.jpa.CompanySummaryDto(...)) is a plain string – the compiler does not validate it. With the default eager repository bootstrap, a wrong class name or mismatched constructor fails at application startup during query validation. Only underBootstrapMode.LAZYis this deferred to first interaction. IDEs like IntelliJ IDEA can resolve the reference and warn you, but it is not a compile-time guarantee. - Constructor argument order is not validated by the compiler. The
newexpression maps arguments positionally – a same-type positional swap (for example,nameandcountryCodeboth beingString) passes all validation but silently assigns wrong values to wrong fields. Always keep the JPQL argument list and the constructor parameter list in sync, and cover the mapping with a test that asserts specific field values.
When to use:
- Public API contracts where you need an explicit, stable response model that can evolve independently from the entity (for example, introducing
CompanySummaryDtoV2for a new API version without touching the original). - Cases where you want the mapping to be explicit and visible in code – for example, when field names don’t match or you need to transform values (like combining
firstNameandlastNameintofullName).
Dynamic projection (choose shape at call site)
A dynamic projection lets you reuse one query method and decide the result shape at the call site by passing a Class<T> parameter. Spring Data JPA detects the Class<T> argument at startup, and at each invocation reads the actual class value to choose how to transform the result.
interface CompanyRepository : JpaRepository<Company, UUID> {
fun <T> findByCountryCode(countryCode: String, type: Class<T>): List<T>
}All three shapes work from the same method:
// Interface-based projection - SQL fetches only declared columns
val summaries = repo.findByCountryCode("DE", CompanySummaryView::class.java)
// DTO (data class) - selects only DTO constructor properties; no interface proxy
val items = repo.findByCountryCode("DE", CompanySummaryDto::class.java)
// Full entity - all columns fetched
val entities = repo.findByCountryCode("DE", Company::class.java)Important distinction: both interface and DTO projections reduce SQL columns in derived queries – only the entity shape selects all entity columns. If column reduction matters and the derived query shape is insufficient, use an explicit field-selecting @Query with an interface or DTO projection.
The ::class.java syntax is a Java idiom. In Kotlin you can hide it with a reified inline extension:
inline fun <reified T> CompanyRepository.findByCountryCode(countryCode: String): List<T> =
findByCountryCode(countryCode, T::class.java)Kotlin note: member functions shadow extension functions when the signatures match. If the repository already declares findByCountryCode(String): List<Company>, calling repo.findByCountryCode("DE") without a type argument resolves to the member. Provide an explicit type argument so the compiler picks the reified extension:
// Explicit type argument required - member would otherwise shadow the extension
val summaries = repo.findByCountryCode<CompanySummaryView>("DE")
val entities = repo.findByCountryCode<Company>("DE")When to use:
- Same filter logic needed by multiple callers that each require a different result shape – for example, a list API (summary projection), a detail API (full entity), and an event publisher (specific fields).
- Internal service utilities or generic base services where the caller decides the shape.
When not to use:
- Public API controllers – prefer explicit return types for stable, documentable contracts.
- Queries with joins, aggregations, or complex logic where different result shapes actually require different SQL – one query cannot serve all shapes efficiently.
- Native queries – dynamic
Class<T>works when native result columns match DTO constructor arguments in order and type. For mismatched or transformed results, use@NativeQuery(resultSetMapping = …)with@SqlResultSetMapping.
DTO mapping approaches beyond projections
When projections are not enough – for example, when you need to map write requests to entities, combine data from multiple sources, or produce complex nested response shapes – you need a mapper.
Handwritten extension functions
The idiomatic Kotlin approach is a plain extension function on the entity. No dependencies, no configuration, full null safety enforced by the compiler, and trivial to debug. This should be your default choice for most Kotlin projects.
fun Company.toSummaryDto() = CompanySummaryDto(
id = requireNotNull(id),
name = name,
countryCode = countryCode,
status = status
)For write-path mapping (request → entity), an extension function works the same way:
fun CreateCompanyRequest.toEntity() = Company(
name = name,
countryCode = countryCode
)If you need Spring injection (for example, when a mapper depends on another service), group mappings in a @Component class instead:
@Component
class CompanyMapper {
fun toSummaryDto(company: Company) = CompanySummaryDto(
id = requireNotNull(company.id),
name = company.name,
countryCode = company.countryCode,
status = company.status
)
}When to use:
- Recommended default for most Kotlin projects regardless of size.
- When mappings contain non-trivial logic that a code generator cannot express cleanly.
- When you want zero extra dependencies and full compiler-enforced null safety.
If extension functions produce too much boilerplate in a large codebase, Konvert is the next step.
Konvert
Konvert is a compile-time code generator built on KSP (Kotlin Symbol Processing). It generates actual Kotlin extension functions and fully respects Kotlin null safety.
plugins {
id("com.google.devtools.ksp") version "2.2.21-2.0.5" // match your Kotlin version
}
dependencies {
implementation("io.mcarle:konvert-api:4.4.0")
ksp("io.mcarle:konvert:4.4.0")
}Annotate the entity with @KonvertTo and Konvert generates the extension function automatically:
@KonvertTo(CompanySummaryDto::class)
class Company(...)
// Generated: Company.toCompanySummaryDto()
val dto = company.toCompanySummaryDto()When to use:
- When extension functions produce too much boilerplate across many entities and DTOs.
- When starting a new Kotlin project and want a code generator that generates idiomatic Kotlin output.
Worth to know: MapStruct
MapStruct is a well-known mapping library in the Java/Spring ecosystem and you will see it in many Spring Boot projects. It generates mapper implementations at compile time from annotated interfaces and has strong IDE support in IntelliJ IDEA.
However, for Kotlin projects there are two reasons to think twice before adopting it. First, MapStruct is a Java annotation processor, so Kotlin projects commonly run it through kapt. kapt is in maintenance mode, while KSP is the Kotlin-first processing API and the better default for new Kotlin code generators. Second, MapStruct 1.6.x (current stable) processes Kotlin projects by generating Java source files, so Kotlin null safety and data class semantics have historically been weaker in the generated output. MapStruct 1.7.0.Beta1 significantly improves Kotlin data class support via kotlin-metadata-jvm, but KSP support is not yet available in any stable release.
If you are maintaining a large Java codebase that is gradually migrating to Kotlin, MapStruct is a reasonable tool to keep. For new Kotlin projects, prefer handwritten extension functions or Konvert.
At the end
I hope this article will be helpful to you; it’s one of the largest materials I’ve ever prepared. If you like it, follow me on LinkedIn.
If you have time and are ready to read about a topic other than Spring Boot Data JPA, I highly recommend reading my IntelliJ Plugin: Building Docker Security Analysis Tools.
