Choosing an Elasticsearch client for Kotlin and Spring Boot
This chapter starts the Kotlin half of the series. You create a Gradle multi-module project with a shared search-index module, used by every application from here on, and a client-tour application that calls the lab’s user-profile-v1 index through each client API in turn: the typed Elasticsearch Java API Client, its asynchronous variant from coroutines, the low-level REST client, and Spring Data Elasticsearch’s repositories and ElasticsearchOperations. By the end you know what each one is good for, and you have hit the two problems that surprise Spring Boot 4 users: Kotlin data classes that will not deserialise, and totals that stop at 10,000.
The chapter assumes you know Spring Boot and Kotlin; neither is explained. You need the lab with user-profile-v1 loaded (chapter 05), JDK 21, and a local Gradle installation to generate the wrapper. It takes about 45 minutes.
The clients, and what each is for
| Client | What it is | Use it for |
|---|---|---|
Elasticsearch Java API Client (ElasticsearchClient) | Typed request and response classes for every API, generated from Elasticsearch’s API specification | Everything the Search API and indexer do: queries, bulk, aliases, index management |
ElasticsearchAsyncClient | The same API returning CompletableFuture | Concurrent calls from coroutines or reactive code |
Low-level Rest5Client | HTTP requests and raw responses, with connection pooling and node selection | Endpoints or formats the typed client does not model, such as _cat text output |
| Spring Data Elasticsearch | Repositories, ElasticsearchOperations, and object mapping on top of the Java API Client | CRUD-style access and simple queries where its abstractions fit |
The version pairing matters. Spring Boot 4.1.1 manages co.elastic.clients:elasticsearch-java 9.4.5 and Spring Data Elasticsearch 6.1.1, which the Spring Data compatibility table lists against Elasticsearch 9.4. The Java client documentation states that the client is forward compatible with later minor versions of the server, so it works with the lab’s 9.5.4. It cannot use features added in 9.5, and nothing in this series needs them.
Stage 1 — Create the project
Create a directory user-search/ next to the lab directory. This is the application code base for the rest of the series. Generate a Gradle wrapper in it with any local Gradle 8.14 or later:
mkdir user-search && cd user-searchgradle wrapper --gradle-version 9.6.0Declare the modules. Chapter 10 adds search-api and chapter 14 adds indexer.
rootProject.name = "user-search"
include("search-index", "client-tour")The root build applies the plugins to no module; each module applies what it needs. There is no dependency-management plugin: modules import Spring Boot’s bill of materials with Gradle’s own platform() support, which the Boot plugin exposes as SpringBootPlugin.BOM_COORDINATES.
plugins { kotlin("jvm") version "2.3.21" apply false kotlin("plugin.spring") version "2.3.21" apply false id("org.springframework.boot") version "4.1.1" apply false}
subprojects { group = "in.o612.eng.usersearch" version = "0.1.0" repositories { mavenCentral() }}The shared search-index module
Everything about the index that more than one application needs lives here: the document class, the index names and definitions, and the JSON configuration of the client. It is a plain Kotlin library with no Spring dependency.
import org.springframework.boot.gradle.plugin.SpringBootPlugin
plugins { kotlin("jvm")}
kotlin { jvmToolchain(21) }
dependencies { implementation(platform(SpringBootPlugin.BOM_COORDINATES)) api("co.elastic.clients:elasticsearch-java") implementation("tools.jackson.module:jackson-module-kotlin")}Copy the reviewed index definition from the lab into the module’s resources, so applications create indices from the same file you reviewed in chapter 05:
mkdir -p search-index/src/main/resources/escp ../user-search-lab/es/user-profile-v1.json search-index/src/main/resources/es/The document class mirrors the mapping, field for field:
package `in`.o612.eng.usersearch.index
import java.time.Instantimport java.time.LocalDate
/** One profile as stored in the user-profile index. Field names match the mapping. */data class UserProfileDocument( val userId: String, val fullName: String, val firstName: String, val lastName: String, val email: String, val mobileNumber: String, val city: String, val state: String, val country: String, val pincode: String, val dateOfBirth: LocalDate?, val gender: String, val accountStatus: String, val createdAt: Instant, val updatedAt: Instant,)Index and alias names are defined once:
package `in`.o612.eng.usersearch.index
/** Names and definitions of the versioned user-profile index. */object UserProfileIndex { const val READ_ALIAS = "user-profile-read" const val WRITE_ALIAS = "user-profile-write"
fun indexName(version: Int) = "user-profile-v$version"
/** The reviewed index definition for [version], from `es/user-profile-v<version>.json`. */ fun definition(version: Int): String { val path = "/es/user-profile-v$version.json" val stream = UserProfileIndex::class.java.getResourceAsStream(path) ?: error("No index definition at $path") return stream.use { it.readBytes().decodeToString() } }}The third file exists because of a problem you would otherwise meet at runtime, and Stage 3 reproduces it:
package `in`.o612.eng.usersearch.index
import co.elastic.clients.json.JsonpMapperimport co.elastic.clients.json.jackson.Jackson3JsonpMapperimport com.fasterxml.jackson.annotation.JsonIncludeimport tools.jackson.databind.json.JsonMapperimport tools.jackson.module.kotlin.KotlinModule
/** * The JSON mapper for the Elasticsearch client. * * Spring Boot's default, `Jackson3JsonpMapper()`, registers no modules, so it cannot * construct Kotlin data classes. This one adds the Kotlin module and keeps the * client's default of omitting null properties. */object ElasticsearchJson { fun jsonpMapper(): JsonpMapper = Jackson3JsonpMapper( JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .changeDefaultPropertyInclusion { it.withValueInclusion(JsonInclude.Include.NON_NULL) } .build(), )}The client-tour application
A small non-web Spring Boot application. It also depends on Spring Data Elasticsearch, which the real services in later chapters do not.
import org.springframework.boot.gradle.plugin.SpringBootPlugin
plugins { kotlin("jvm") kotlin("plugin.spring") id("org.springframework.boot")}
kotlin { jvmToolchain(21) }
dependencies { implementation(platform(SpringBootPlugin.BOM_COORDINATES)) implementation(project(":search-index")) implementation("org.springframework.boot:spring-boot-starter-elasticsearch") implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch") implementation("tools.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")}Connection settings come from environment variables, never from the file. Spring Boot’s spring.elasticsearch.* properties configure the auto-configured Rest5Client and ElasticsearchClient, as the Spring Boot Elasticsearch reference describes.
spring: main: web-application-type: none elasticsearch: uris: ${ELASTICSEARCH_URIS:http://localhost:9200} username: ${ELASTICSEARCH_USERNAME} password: ${ELASTICSEARCH_PASSWORD}The application runs every tour step and then exits. Closing the context matters: the client’s I/O threads would otherwise keep the JVM running after the last step.
package `in`.o612.eng.usersearch.tour
import org.springframework.boot.SpringApplicationimport org.springframework.boot.autoconfigure.SpringBootApplicationimport org.springframework.boot.runApplicationimport kotlin.system.exitProcess
@SpringBootApplicationclass ClientTourApplication
fun main(args: Array<String>) { // Run every tour step, then close the context so the client's I/O threads stop. val context = runApplication<ClientTourApplication>(*args) exitProcess(SpringApplication.exit(context))}package `in`.o612.eng.usersearch.tour
import co.elastic.clients.json.JsonpMapperimport `in`.o612.eng.usersearch.index.ElasticsearchJsonimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration
@Configurationclass ElasticsearchConfig { @Bean fun jsonpMapper(): JsonpMapper = ElasticsearchJson.jsonpMapper()}Stage 2 — The typed Java API Client from Kotlin
What it is. ElasticsearchClient exposes one method per API. Each takes either a request object or a lambda that configures the request’s builder, which reads well in Kotlin. Responses are typed, and document sources are deserialised into a class you pass in. The API conventions page explains the builder and lambda patterns.
Why it matters at 300 million profiles. The Search API composes queries from optional parts: a name, any combination of filters, a sort, a cursor. A typed builder makes those compositions checked at compile time, and it covers every API the series needs, including point-in-time searches, bulk requests, and alias actions that higher-level abstractions leave out.
Example.
package `in`.o612.eng.usersearch.tour
import co.elastic.clients.elasticsearch.ElasticsearchClientimport co.elastic.clients.elasticsearch._types.FieldValueimport co.elastic.clients.elasticsearch._types.query_dsl.Operatorimport co.elastic.clients.elasticsearch.core.SearchRequestimport `in`.o612.eng.usersearch.index.UserProfileDocumentimport `in`.o612.eng.usersearch.index.UserProfileIndeximport org.springframework.boot.ApplicationRunnerimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.core.annotation.Orderimport java.io.StringReader
@Configurationclass TypedClientTour {
@Bean @Order(1) fun typedClient(client: ElasticsearchClient) = ApplicationRunner { println("JSON mapper: ${client._jsonpMapper().javaClass.simpleName}")
// Get by id: one shard, real time, deserialised into a Kotlin data class. val profile = client.get( { it.index(UserProfileIndex.READ_ALIAS).id("42") }, UserProfileDocument::class.java, ).source() println("get 42 -> ${profile?.fullName}, ${profile?.city}, ${profile?.accountStatus}")
// Search built with the typed DSL. val response = client.search({ s -> s.index(UserProfileIndex.READ_ALIAS) .size(3) .source { src -> src.filter { f -> f.includes("userId", "fullName", "city") } } .query { q -> q.bool { b -> b.must { m -> m.match { mt -> mt.field("fullName.prefix").query("prashant ku").operator(Operator.And) } }.filter { f -> f.term { t -> t.field("state").value(FieldValue.of("bihar")) } }.filter { f -> f.term { t -> t.field("accountStatus").value(FieldValue.of("ACTIVE")) } } } } }, ProfileSummary::class.java) println("typed search -> total ${response.hits().total()?.value()}, first ${response.hits().hits().map { it.source()?.fullName }}")
// The same request from JSON, for example pasted from Kibana Dev Tools. val json = """ { "size": 0, "query": { "bool": { "filter": [ { "term": { "state": "bihar" } }, { "term": { "accountStatus": "ACTIVE" } } ] } } } """.trimIndent() val fromJson = SearchRequest.of { it.index(UserProfileIndex.READ_ALIAS).withJson(StringReader(json)) } val total = client.search(fromJson, Void::class.java).hits().total() println("withJson search -> total ${total?.value()} (${total?.relation()})")
// Ask for an exact count when the number matters. val exact = client.search({ s -> s.index(UserProfileIndex.READ_ALIAS).size(0).trackTotalHits { t -> t.enabled(true) } .query { q -> q.term { t -> t.field("state").value(FieldValue.of("bihar")) } } }, Void::class.java).hits().total() println("exact total -> ${exact?.value()} (${exact?.relation()})") }}
/** Only the fields a result list needs. */data class ProfileSummary(val userId: String, val fullName: String, val city: String)Three patterns are worth noticing. A get by ID goes straight to one shard and returns a UserProfileDocument. The search asks only for three source fields and deserialises them into ProfileSummary, a class that contains only what a result list needs; a class can be narrower than the document. And withJson builds a request from JSON text, so a query you developed in Kibana Dev Tools can be pasted in unchanged, as the loading JSON guide describes.
The last request in the file asks for an exact total. Stage 5 explains why that is needed.
Stage 3 — The Kotlin deserialisation trap
Before you run anything, see what happens without ElasticsearchConfig. Delete that file temporarily and run the tour:
set -a; source ../user-search-lab/.env; set +aexport ELASTICSEARCH_USERNAME=elastic ELASTICSEARCH_PASSWORD="$ELASTIC_PASSWORD"./gradlew :client-tour:bootRunJSON mapper: Jackson3JsonpMapper...Caused by: co.elastic.clients.transport.TransportException: node: http://localhost:9200/, status: 200, [es/get] Failed to decode response...Caused by: tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of`in.o612.eng.usersearch.index.UserProfileDocument` (no Creators, like default constructor, exist):cannot deserialize from Object value (no delegate- or property-based Creator)Spring Boot 4.1 configures the client with Jackson3JsonpMapper, the client’s Jackson 3 integration. It creates that mapper with its no-argument constructor, which registers no Jackson modules. The Kotlin module is on the classpath, but that mapper never sees it, so Jackson cannot call a data class’s constructor. The fix is to supply your own JsonpMapper bean, which Spring Boot’s auto-configuration backs off from. ElasticsearchJson.jsonpMapper() builds one with the Kotlin module and keeps the client’s default of leaving null properties out of requests. Restore ElasticsearchConfig.kt before you continue.
Production note — The failure appears only when a response source is deserialised into one of your classes. The client’s own request and response types use their own deserialisers, so a search that only counts hits, such as the one with
Void::class.javain Stage 2, does not exercise this code path. A service that returns only IDs and totals can pass its tests with the default mapper and fail the first time someone adds a typed source class.
Stage 4 — Run the tour
With ElasticsearchConfig.kt in place, add the remaining tour steps, then run everything.
The low-level client sends any HTTP request through the same connection pool and returns the raw response. Here it fetches _cat text output, which the typed client does not model. See the Rest5Client documentation.
package `in`.o612.eng.usersearch.tour
import co.elastic.clients.transport.rest5_client.low_level.Requestimport co.elastic.clients.transport.rest5_client.low_level.Rest5Clientimport org.springframework.boot.ApplicationRunnerimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.core.annotation.Order
@Configurationclass LowLevelClientTour {
@Bean @Order(2) fun lowLevelClient(restClient: Rest5Client) = ApplicationRunner { val request = Request("GET", "/_cat/aliases/user-profile-*").apply { addParameter("v", "true") addParameter("h", "alias,index,is_write_index") } val response = restClient.performRequest(request) println("low-level status ${response.statusCode}") print(response.entity.content.readAllBytes().decodeToString()) }}The asynchronous client shares the blocking client’s transport, so both use one connection pool. await() from kotlinx-coroutines-core turns its CompletableFuture results into suspending calls, and three counts run concurrently:
package `in`.o612.eng.usersearch.tour
import co.elastic.clients.elasticsearch.ElasticsearchAsyncClientimport co.elastic.clients.elasticsearch.ElasticsearchClientimport co.elastic.clients.elasticsearch._types.FieldValueimport `in`.o612.eng.usersearch.index.UserProfileIndeximport kotlinx.coroutines.asyncimport kotlinx.coroutines.coroutineScopeimport kotlinx.coroutines.future.awaitimport kotlinx.coroutines.runBlockingimport org.springframework.boot.ApplicationRunnerimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.core.annotation.Order
@Configurationclass AsyncClientTour {
@Bean @Order(3) fun asyncClient(client: ElasticsearchClient) = ApplicationRunner { // Shares the blocking client's transport: one connection pool for both. val async = ElasticsearchAsyncClient(client._transport())
suspend fun countIn(state: String): Long = async.count { c -> c.index(UserProfileIndex.READ_ALIAS) .query { q -> q.term { t -> t.field("state").value(FieldValue.of(state)) } } }.await().count()
runBlocking { coroutineScope { val states = listOf("bihar", "kerala", "karnataka") val counts = states.map { state -> async { state to countIn(state) } }.map { it.await() } println("async counts -> $counts") } } }}Spring Data Elasticsearch maps an annotated class to the index and generates repository queries from method names. NativeQuery embeds a query built with the Java client’s builder inside Spring Data’s paging and mapping. The repositories reference and object mapping reference document both.
package `in`.o612.eng.usersearch.tour
import co.elastic.clients.elasticsearch._types.FieldValueimport `in`.o612.eng.usersearch.index.UserProfileIndeximport org.springframework.boot.ApplicationRunnerimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.core.annotation.Orderimport org.springframework.data.annotation.Idimport org.springframework.data.domain.Pageimport org.springframework.data.domain.PageRequestimport org.springframework.data.domain.Pageableimport org.springframework.data.domain.Sortimport org.springframework.data.elasticsearch.annotations.DateFormatimport org.springframework.data.elasticsearch.annotations.Documentimport org.springframework.data.elasticsearch.annotations.Fieldimport org.springframework.data.elasticsearch.annotations.FieldTypeimport org.springframework.data.elasticsearch.client.elc.NativeQueryimport org.springframework.data.elasticsearch.core.ElasticsearchOperationsimport org.springframework.data.elasticsearch.core.mapping.IndexCoordinatesimport org.springframework.data.elasticsearch.repository.ElasticsearchRepositoryimport java.time.Instant
/** The profile as Spring Data sees it. createIndex = false: the index is managed from reviewed JSON. */@Document(indexName = UserProfileIndex.READ_ALIAS, createIndex = false)data class ProfileEntity( @Id val userId: String, val fullName: String, val city: String, val state: String, val accountStatus: String, @Field(type = FieldType.Date, format = [DateFormat.date_time_no_millis]) val updatedAt: Instant,)
interface ProfileRepository : ElasticsearchRepository<ProfileEntity, String> { fun findByCityAndAccountStatus(city: String, accountStatus: String, pageable: Pageable): Page<ProfileEntity>}
@Configurationclass SpringDataTour {
@Bean @Order(4) fun springData(repository: ProfileRepository, operations: ElasticsearchOperations) = ApplicationRunner { // A derived query: Spring Data writes the query from the method name. val page = repository.findByCityAndAccountStatus( "patna", "ACTIVE", PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt")), ) println("repository -> total ${page.totalElements}, first ${page.content.map { "${it.fullName} ${it.updatedAt}" }}")
// NativeQuery: Spring Data's paging and mapping around the Java client's query builder. val query = NativeQuery.builder() .withQuery { q -> q.bool { b -> b.must { m -> m.match { mt -> mt.field("fullName").query("prashant kumar") } } .filter { f -> f.term { t -> t.field("state").value(FieldValue.of("bihar")) } } } } .withPageable(PageRequest.of(0, 3)) .withTrackTotalHits(true) .build() val hits = operations.search(query, ProfileEntity::class.java, IndexCoordinates.of(UserProfileIndex.READ_ALIAS)) println("operations -> total ${hits.totalHits}, scores ${hits.searchHits.map { it.score }}") }}Run the tour:
./gradlew -q :client-tour:bootRunJSON mapper: Jackson3JsonpMapperget 42 -> Suresh Sharma, Gaya, SUSPENDEDtyped search -> total 237, first [Prashant Kumar, Prashant Kumar, Prashant Kumar]withJson search -> total 10000 (Gte)exact total -> 199755 (Eq)low-level status 200alias index is_write_indexuser-profile-read user-profile-v1 -user-profile-write user-profile-v1 trueasync counts -> [(bihar, 199755), (kerala, 99667), (karnataka, 100159)]repository -> total 10000, first [Ananya Jha 2026-08-28T00:00:00Z, Sanjay Mishra 2026-08-26T00:00:00Z, Sanjay Mishra 2026-08-26T00:00:00Z]operations -> total 16378, scores [6.396736, 6.396736, 6.396736]Spring Boot’s startup log lines are omitted here. The JSON mapper is still Jackson3JsonpMapper, now your instance of it, with the Kotlin module. Profile 42 carries chapter 04’s changes. The typed autocomplete search returns the 237 active Prashant Kumars in Bihar from chapter 07.
Checkpoint
The run ends with exit code 0 and prints every line in that output. If it hangs after operations ->, the exitProcess call in ClientTourApplication.kt is missing. If it fails with 401, the environment variables were not exported in the same shell.
Stage 5 — Totals stop at 10,000 unless you ask
Look again at two lines of the output. The search built with withJson counted 10000 (Gte), and the repository reported total 10000. The true counts are 199,755 profiles in Bihar and 70,214 active profiles in Patna.
What it is. By default a search counts matching documents exactly only up to 10,000, then reports relation: gte (“at least”), because counting every match of a broad query costs time. The track_total_hits parameter of the search API changes that: true counts exactly, and a number counts exactly up to that number.
Why it matters at 300 million profiles. Most searches match far more than 10,000 profiles. A UI that shows “10,000 results” as a fact is wrong. An exact count on every request costs work on every shard, for a number few users need.
Example. The Java client exposes both parts: total.value() and total.relation(). The tour’s exact count sets trackTotalHits { t -> t.enabled(true) } and gets 199755 (Eq). The NativeQuery in SpringDataTour sets withTrackTotalHits(true) and reports the true 16,378 matches for prashant kumar in Bihar.
The repository cannot do either. Page.totalElements is a plain Long, with nowhere to put a relation, so the derived query’s 10000 looks exactly like an exact count of 10,000.
Common mistake. Displaying totalElements from a Spring Data Page as the number of results. Either track totals exactly where the number matters, or show “10,000+” when the relation is gte. Chapter 13’s API contract returns both the value and the relation.
Where Spring Data Elasticsearch helps, and where it stops
Spring Data Elasticsearch is productive for what it was designed for: mapping documents to classes, CRUD, derived queries on a few fields, and paging with familiar Spring types. For this system, several of its defaults and abstractions work against the design from earlier chapters.
| Need in this series | Spring Data Elasticsearch | Java API Client |
|---|---|---|
| Index created from reviewed JSON with custom analysers, normalisers, and aliases | @Document creates an index from annotations by default (createIndex defaults to true); @Setting can load a settings file | Creates exactly the JSON you reviewed, with withJson |
| Separate read and write aliases, atomic alias swap | Possible through IndexOperations and alias actions, outside repositories | updateAliases with add and remove actions in one request |
| External versions from PostgreSQL on every write | @Document(versionType = EXTERNAL) together with a @Version property on the entity | Per request: versionType(External), version(rowVersion) |
| Total hits with their relation | SearchHits exposes a relation; repository Page does not | hits().total() with value() and relation() |
search_after with a point in time, highlighting, source filtering per request | Available through NativeQuery and SearchHits, not through derived repository methods | Every option of the search API |
| Bulk ingestion with per-item error classification | bulkIndex throws a BulkFailureException with the failed items; classifying and retrying them is yours | BulkIngester helper, with per-item responses (chapter 14) |
Three defaults deserve explicit settings if you do use Spring Data. createIndex = false stops it from creating an index with a generated mapping when the name it expects is missing, which would bypass everything chapter 05 designed. A @Document(indexName = ...) should name an alias, not a versioned index. And its writes use Elasticsearch’s internal versioning unless you configure external versioning, which reintroduces chapter 04’s ordering problems.
Recommendation. The Search API (chapter 10) and the indexer (chapter 14) use ElasticsearchClient directly. Their core work, composed queries, cursor pagination, versioned bulk writes, and alias management, is exactly where Spring Data’s abstractions stop helping. For an admin tool or a secondary service that reads profiles by ID and runs a few fixed queries, Spring Data repositories on the read alias are a reasonable choice.
Trade-off. Using the Java API Client means writing the query-building code yourself, so it needs its own unit tests (chapter 17). In return, every request the service sends is visible in the code that sends it.
What you built, and what comes next
You have a multi-module Gradle project with a shared search-index library, and a tour application that exercises the typed, asynchronous, and low-level clients and Spring Data Elasticsearch against the lab. You fixed Spring Boot 4’s default JSON mapper for Kotlin data classes, and you saw why totals need track_total_hits and an explicit relation.
The tour prints results; it does not handle errors, time out requests, or secure its connection. Chapter 10 does all three.
Chapter 10 builds the Search API: a Spring Boot web service with request and response DTOs, a query service, a controller, an index bootstrapper that creates versioned indices from the reviewed JSON, and a Docker Compose configuration that turns TLS on.