Series overview
Part 10 of 1856% complete
2026-05-19•9 min read

Building the Spring Boot search service

This chapter builds the Search API from chapter 04’s architecture. By the end, a Spring Boot 4 service answers GET /api/users/search with filtered, sorted name search, and exact lookups by user ID, email, and mobile number, reading only through user-profile-read over an encrypted connection. It also has an opt-in component that creates the index and its synonyms from the reviewed JSON on an empty cluster, validation that rejects requests outside the capability matrix, and error handling that turns an unreachable cluster into a clean 503.

First, the lab’s Elasticsearch moves from plain HTTP to TLS, as chapter 01 promised. Then the search-api module is added to the chapter 09 project. The query builder here is deliberately simple; chapter 11 replaces it with the full query design. You need the lab and the user-search project from chapter 09. The chapter takes about 60 minutes.

Stage 1 — Turn TLS on in the lab

Until now, credentials and profile data crossed the lab’s network in plain text. Elasticsearch’s HTTP layer supports TLS with certificates you supply. For the lab, a one-shot container creates a private certificate authority (CA) and a certificate for the node with elasticsearch-certutil, the tool shipped in the Elasticsearch image.

Create es/make-certs.sh in the lab directory:

es/make-certs.sh
#!/usr/bin/env bash
# Creates a private CA and a certificate for the Elasticsearch node, once.
# LOCAL-DEV SHORTCUT: a self-signed CA on disk. Production uses your organisation's PKI.
set -euo pipefail
CERTS=/usr/share/elasticsearch/config/certs
cd "$CERTS"
if [[ ! -f ca/ca.crt ]]; then
elasticsearch-certutil ca --silent --pem --out "$CERTS/ca.zip"
unzip -q ca.zip && rm ca.zip
fi
if [[ ! -f elasticsearch/elasticsearch.crt ]]; then
cat > instances.yml <<'YAML'
instances:
- name: elasticsearch
dns: [elasticsearch, localhost]
ip: [127.0.0.1]
YAML
# certutil resolves relative paths against its own home directory, so pass absolute ones.
elasticsearch-certutil cert --silent --pem --in "$CERTS/instances.yml" \
--ca-cert "$CERTS/ca/ca.crt" --ca-key "$CERTS/ca/ca.key" --out "$CERTS/certs.zip"
unzip -q certs.zip && rm certs.zip
fi
# Elasticsearch runs as uid 1000. Only the CA certificate is world-readable.
chown -R 1000:0 "$CERTS"
find "$CERTS" -type d -exec chmod 750 {} +
find "$CERTS" -type f -exec chmod 640 {} +
chmod 755 "$CERTS" "$CERTS/ca"
chmod 644 "$CERTS/ca/ca.crt"
echo "certificates ready"

Two details come from running it. elasticsearch-certutil resolves relative paths against its own home directory, not the current directory, so every path passed to it is absolute. And only the CA certificate is readable by everyone, because the host’s curl and the Spring service need it; the private keys are not.

Replace compose.yaml with this version. The changes from chapter 01 are the new certs service, the xpack.security.http.ssl.* settings and certificate mount on elasticsearch, HTTPS and the CA in the two health checks, and the CA setting on Kibana.

compose.yaml
name: user-search-lab
services:
postgres:
image: postgres:18
environment:
POSTGRES_DB: profiles
POSTGRES_USER: profiles
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U profiles -d profiles"]
interval: 5s
retries: 30
# One-shot job: creates a private CA and a node certificate, once. See es/make-certs.sh.
certs:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
user: "0"
volumes:
- ./certs:/usr/share/elasticsearch/config/certs
- ./es/make-certs.sh:/usr/local/bin/make-certs.sh:ro
command: ["bash", "/usr/local/bin/make-certs.sh"]
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
depends_on:
certs: { condition: service_completed_successfully }
environment:
discovery.type: single-node
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD}
xpack.security.enabled: "true"
xpack.security.http.ssl.enabled: "true"
xpack.security.http.ssl.key: certs/elasticsearch/elasticsearch.key
xpack.security.http.ssl.certificate: certs/elasticsearch/elasticsearch.crt
xpack.security.http.ssl.certificate_authorities: certs/ca/ca.crt
xpack.license.self_generated.type: basic
ES_JAVA_OPTS: -Xms1g -Xmx1g
mem_limit: ${ES_MEM_LIMIT}
ulimits:
memlock: { soft: -1, hard: -1 }
ports:
- "127.0.0.1:9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
- ./certs:/usr/share/elasticsearch/config/certs:ro
healthcheck:
test: ["CMD-SHELL", "curl -s --cacert config/certs/ca/ca.crt -u elastic:${ELASTIC_PASSWORD} https://localhost:9200/_cluster/health | grep -Eq '\"status\":\"(green|yellow)\"'"]
interval: 10s
retries: 30
# One-shot job: gives the built-in kibana_system user a password.
# Kibana should not connect as the elastic superuser.
kibana-setup:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
depends_on:
elasticsearch: { condition: service_healthy }
restart: "no"
volumes:
- ./certs:/usr/share/elasticsearch/config/certs:ro
command: >
bash -c 'until curl -s --cacert config/certs/ca/ca.crt -o /dev/null -w "%{http_code}" -u "elastic:${ELASTIC_PASSWORD}"
-X POST https://elasticsearch:9200/_security/user/kibana_system/_password
-H "Content-Type: application/json" -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q 200;
do sleep 5; done; echo kibana_system password set'
kibana:
image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
depends_on:
kibana-setup: { condition: service_completed_successfully }
environment:
ELASTICSEARCH_HOSTS: https://elasticsearch:9200
ELASTICSEARCH_USERNAME: kibana_system
ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD}
ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES: /usr/share/kibana/config/certs/ca/ca.crt
volumes:
- ./certs:/usr/share/kibana/config/certs:ro
ports:
- "127.0.0.1:5601:5601"
volumes:
pgdata:
esdata:

Apply it. Compose recreates the changed containers and keeps both data volumes, so the index and the profile table survive:

Terminal window
docker compose up -d

When docker compose ps -a shows certs and kibana-setup as Exited (0) and the other services up, check the connection three ways:

Terminal window
set -a; source .env; set +a
curl -s --cacert certs/ca/ca.crt -u "elastic:$ELASTIC_PASSWORD" \
"https://localhost:9200/user-profile-read/_count?filter_path=count"
{"count":1000000}

Plain HTTP is now refused, and HTTPS without the CA fails certificate verification:

$ curl -s -o /dev/null -w '%{http_code} %{errormsg}\n' -u "elastic:$ELASTIC_PASSWORD" http://localhost:9200/
000 Empty reply from server
$ curl -s -o /dev/null -w '%{http_code} %{errormsg}\n' -u "elastic:$ELASTIC_PASSWORD" https://localhost:9200/
000 SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)

Kibana Dev Tools keeps working unchanged, because Kibana now trusts the same CA. From here on, curl commands in this series use https://localhost:9200 with --cacert certs/ca/ca.crt.

Update chapter 05’s loader the same way: in es/load-v1.sh, set ES="https://localhost:9200", add a line CA="certs/ca/ca.crt", and add --cacert "$CA" to each of its four curl commands. Run it once; every item returns 409, and it reports Failed items: 0.

Security note — This is TLS for the lab, with shortcuts that must not reach production. The CA’s private key sits on disk next to the certificates, and the certificate is valid for localhost. A single node also has no inter-node traffic, so transport-layer TLS, which a multi-node cluster must enable, is not configured here. In production, certificates come from your organisation’s PKI or a managed service, and the application trusts the CA through its deployment secrets. Elastic’s security setup documentation covers both layers.

Stage 2 — Add the search-api module

Add the module to settings.gradle.kts in the user-search project:

settings.gradle.kts
rootProject.name = "user-search"
include("search-index", "client-tour", "search-api")

The build uses only what the service needs: web MVC, validation, the Elasticsearch client starter, and Kotlin support for Jackson. Spring Data Elasticsearch is not a dependency, for the reasons in chapter 09.

search-api/build.gradle.kts
import org.springframework.boot.gradle.plugin.SpringBootPlugin
plugins {
kotlin("jvm")
kotlin("plugin.spring")
id("org.springframework.boot")
}
kotlin {
jvmToolchain(21)
compilerOptions { freeCompilerArgs.add("-Xjsr305=strict") }
}
dependencies {
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
implementation(project(":search-index"))
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-elasticsearch")
implementation("tools.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
}

The configuration names every connection setting, and reads every value from the environment:

search-api/src/main/resources/application.yaml
spring:
application:
name: search-api
elasticsearch:
uris: ${ELASTICSEARCH_URIS}
username: ${ELASTICSEARCH_USERNAME}
password: ${ELASTICSEARCH_PASSWORD}
connection-timeout: 2s
socket-timeout: 5s
restclient:
ssl:
bundle: elasticsearch
ssl:
bundle:
pem:
elasticsearch:
truststore:
certificate: ${ELASTICSEARCH_CA_CERT}
user-search:
index:
# Creates user-profile-v1 and its aliases at startup if they do not exist.
# Local development only: it needs index-management privileges the Search API should not have.
bootstrap: ${USER_SEARCH_INDEX_BOOTSTRAP:false}
  • Timeouts are explicit. connection-timeout: 2s fails fast when a node is unreachable. socket-timeout: 5s caps how long one request may take, so a slow cluster cannot hold the service’s request threads indefinitely. Needs validation: set the socket timeout from your latency target and Rally measurements, not from this example.
  • TLS trust comes from an SSL bundle. spring.ssl.bundle.pem.elasticsearch loads the CA certificate from ELASTICSEARCH_CA_CERT, and spring.elasticsearch.restclient.ssl.bundle applies it to the client. The certificate file is never on the classpath.
  • Index creation is off by default. Stage 3 explains why.

Security note — The lab passes the elastic superuser’s password in ELASTICSEARCH_PASSWORD. That is a local-development shortcut: the Search API needs read access to one alias and nothing else. Chapter 16 replaces it with an API key restricted to user-profile-read.

Stage 3 — Index management in the shared module

Two applications need to create or inspect the index: this service, in local development and tests, and the indexer in chapter 14. That code belongs in search-index.

Chapter 09’s UserProfileIndex gains the synonyms set, because an empty cluster cannot create the index without it. Replace the file:

search-index/src/main/kotlin/in/o612/eng/usersearch/index/UserProfileIndex.kt
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"
const val SYNONYMS_SET = "profile-name-synonyms"
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 = resource("/es/user-profile-v$version.json")
/** The initial name synonyms, from `es/profile-name-synonyms.json`. */
fun synonymsDefinition(): String = resource("/es/$SYNONYMS_SET.json")
private fun resource(path: String): String {
val stream = UserProfileIndex::class.java.getResourceAsStream(path)
?: error("No resource at $path")
return stream.use { it.readBytes().decodeToString() }
}
}

Add the initial synonyms as a reviewed resource. It holds the lab’s three rules, including the one chapter 07 added:

search-index/src/main/resources/es/profile-name-synonyms.json
{
"synonyms_set": [
{ "id": "mohammed", "synonyms": "mohammed, mohammad, muhammad, mohd" },
{ "id": "lakshmi", "synonyms": "lakshmi, laxmi" },
{ "id": "fatima", "synonyms": "fatima, fathima" }
]
}

IndexAdmin creates an index version from its JSON definition, after making sure the synonyms set exists, and reports where an alias points:

search-index/src/main/kotlin/in/o612/eng/usersearch/index/IndexAdmin.kt
package `in`.o612.eng.usersearch.index
import co.elastic.clients.elasticsearch.ElasticsearchClient
import co.elastic.clients.elasticsearch._types.ElasticsearchException
import co.elastic.clients.elasticsearch.indices.CreateIndexRequest
import co.elastic.clients.elasticsearch.synonyms.PutSynonymRequest
import java.io.StringReader
/** Index and alias management for the user-profile index. Used by applications and jobs, never by search requests. */
class IndexAdmin(private val client: ElasticsearchClient) {
/** Indices an alias currently points to; empty if the alias does not exist. */
fun aliasTargets(alias: String): Set<String> =
if (client.indices().existsAlias { it.name(alias) }.value()) {
client.indices().getAlias { it.name(alias) }.aliases().keys
} else {
emptySet()
}
/**
* Creates `user-profile-v<version>` from its reviewed definition, aliases included.
* Returns false, and changes nothing, if the index already exists.
*/
fun createIndex(version: Int): Boolean {
val name = UserProfileIndex.indexName(version)
if (client.indices().exists { it.index(name) }.value()) return false
ensureSynonymsSet()
val request = CreateIndexRequest.of {
it.index(name).withJson(StringReader(UserProfileIndex.definition(version)))
}
client.indices().create(request)
return true
}
/** The name analysers reference the synonyms set, so it must exist before any index version. */
fun ensureSynonymsSet() {
val exists = try {
client.synonyms().getSynonym { it.id(UserProfileIndex.SYNONYMS_SET) }
true
} catch (e: ElasticsearchException) {
if (e.status() != 404) throw e
false
}
if (!exists) {
client.synonyms().putSynonym(
PutSynonymRequest.of {
it.id(UserProfileIndex.SYNONYMS_SET).withJson(StringReader(UserProfileIndex.synonymsDefinition()))
},
)
}
}
}

createIndex does nothing if the index already exists, so calling it twice is safe. ensureSynonymsSet creates the set only when it is missing, so it never overwrites synonyms added on a running cluster.

In search-api, register the Kotlin-aware JSON mapper from chapter 09 and an IndexAdmin bean:

search-api/src/main/kotlin/in/o612/eng/usersearch/api/config/ElasticsearchConfig.kt
package `in`.o612.eng.usersearch.api.config
import co.elastic.clients.elasticsearch.ElasticsearchClient
import co.elastic.clients.json.JsonpMapper
import `in`.o612.eng.usersearch.index.ElasticsearchJson
import `in`.o612.eng.usersearch.index.IndexAdmin
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class ElasticsearchConfig {
/** Replaces Spring Boot's default mapper, which cannot construct Kotlin data classes (chapter 09). */
@Bean
fun jsonpMapper(): JsonpMapper = ElasticsearchJson.jsonpMapper()
@Bean
fun indexAdmin(client: ElasticsearchClient) = IndexAdmin(client)
}

The bootstrapper runs at startup only when user-search.index.bootstrap is true:

search-api/src/main/kotlin/in/o612/eng/usersearch/api/index/IndexBootstrapper.kt
package `in`.o612.eng.usersearch.api.index
import `in`.o612.eng.usersearch.index.IndexAdmin
import `in`.o612.eng.usersearch.index.UserProfileIndex
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty
import org.springframework.stereotype.Component
/**
* Creates the first index version and its aliases when none exist.
* Enabled only with user-search.index.bootstrap=true, for local development and tests.
*/
@Component
@ConditionalOnBooleanProperty("user-search.index.bootstrap")
class IndexBootstrapper(private val indexAdmin: IndexAdmin) : ApplicationRunner {
private val log = LoggerFactory.getLogger(javaClass)
override fun run(args: ApplicationArguments) {
val targets = indexAdmin.aliasTargets(UserProfileIndex.READ_ALIAS)
if (targets.isNotEmpty()) {
log.info("{} already points to {}; nothing to create", UserProfileIndex.READ_ALIAS, targets)
return
}
val created = indexAdmin.createIndex(version = 1)
log.info("Index {} created: {}", UserProfileIndex.indexName(1), created)
}
}

Why it is opt-in. Creating indices and synonyms sets needs management privileges on the cluster. A search service that holds those privileges can do far more damage if it is compromised or misconfigured than one that can only read an alias. In production, index versions are created by the indexer or a deployment job with its own credentials (chapter 14). The bootstrapper exists so that a developer’s empty cluster, and chapter 17’s Testcontainers tests, get a working index with one property.

Verified against an empty Elasticsearch 9.5.4 node, the first start logs:

INFO ... c.e.u.api.index.IndexBootstrapper : Index user-profile-v1 created: true

and the next start, like a start against the lab:

INFO ... c.e.u.api.index.IndexBootstrapper : user-profile-read already points to [user-profile-v1]; nothing to create

Stage 4 — The request and response contract

The DTOs are the Search API’s public contract, and they encode two decisions from earlier chapters: only the filters the capability matrix allows exist, and result lists never contain contact details.

search-api/src/main/kotlin/in/o612/eng/usersearch/api/web/SearchDtos.kt
package `in`.o612.eng.usersearch.api.web
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size
import java.time.Instant
import java.time.LocalDate
enum class Gender { MALE, FEMALE, OTHER }
enum class AccountStatus { ACTIVE, INACTIVE, SUSPENDED, DELETED }
enum class SortOrder { RELEVANCE, UPDATED_AT }
/** Query parameters of GET /api/users/search. Only the filters chapter 05's capability matrix allows exist. */
data class UserSearchRequest(
@field:Size(min = 2, max = 100) val name: String? = null,
@field:Size(max = 60) val city: String? = null,
@field:Size(max = 60) val state: String? = null,
@field:Pattern(regexp = "\\d{6}") val pincode: String? = null,
val gender: Gender? = null,
val accountStatus: AccountStatus = AccountStatus.ACTIVE,
val updatedSince: Instant? = null,
val sort: SortOrder = SortOrder.RELEVANCE,
@field:Min(1) @field:Max(50) val size: Int = 20,
)
data class UserSearchResponse(
val total: TotalHits,
val hits: List<UserSearchHit>,
)
/** `exact = false` means "at least [value]": Elasticsearch stopped counting (chapter 09). */
data class TotalHits(val value: Long, val exact: Boolean)
/** A search result. Deliberately without email, mobile number, or date of birth. */
data class UserSearchHit(
val userId: String,
val fullName: String,
val city: String,
val state: String,
val accountStatus: String,
val updatedAt: Instant,
val score: Double?,
val highlights: Map<String, List<String>>,
)
/** A single profile, returned only by exact lookups. */
data class ProfileDetail(
val userId: String,
val fullName: String,
val email: String,
val mobileNumber: String,
val city: String,
val state: String,
val pincode: String,
val dateOfBirth: LocalDate?,
val gender: String,
val accountStatus: String,
val updatedAt: Instant,
)
  • UserSearchRequest binds from query parameters, with Bean Validation on each field. size is capped at 50: chapter 13 replaces deeper paging with cursors, and a caller cannot ask for thousands of hits in one response.
  • Enums instead of strings for gender, accountStatus, and sort. Spring rejects any other value with a 400, so the case-sensitive keyword filters from chapter 05 only ever receive valid values.
  • accountStatus defaults to ACTIVE. A caller who forgets the filter does not see suspended or deleted profiles by accident.
  • TotalHits carries exact. Chapter 09 showed that totals stop at 10,000 by default; the contract says which kind of number it is.
  • UserSearchHit has no email, mobile number, or date of birth. Only ProfileDetail, returned by exact lookups, has them.

Stage 5 — Query builder, service, and controller

The query builder turns a validated request into an Elasticsearch request. It performs no I/O, so chapter 17 tests it without a cluster. This first version matches names with one match query; chapter 11 designs the real one.

search-api/src/main/kotlin/in/o612/eng/usersearch/api/search/UserQueryBuilder.kt
package `in`.o612.eng.usersearch.api.search
import co.elastic.clients.elasticsearch._types.FieldValue
import co.elastic.clients.elasticsearch._types.SortOptions
import co.elastic.clients.elasticsearch._types.SortOrder as EsSortOrder
import co.elastic.clients.elasticsearch._types.query_dsl.Operator
import co.elastic.clients.elasticsearch._types.query_dsl.Query
import co.elastic.clients.elasticsearch.core.SearchRequest
import co.elastic.clients.json.JsonData
import `in`.o612.eng.usersearch.api.web.SortOrder
import `in`.o612.eng.usersearch.api.web.UserSearchRequest
import `in`.o612.eng.usersearch.index.UserProfileIndex
/**
* Turns a validated search request into an Elasticsearch request. Pure: no I/O, so it is unit-tested directly.
* This first version matches names with a plain `match` query; chapter 11 replaces it.
*/
object UserQueryBuilder {
/** The only fields a result list may return. */
val RESULT_FIELDS = listOf("userId", "fullName", "city", "state", "accountStatus", "updatedAt")
fun build(request: UserSearchRequest): SearchRequest = SearchRequest.of { s ->
s.index(UserProfileIndex.READ_ALIAS)
.size(request.size)
.source { src -> src.filter { f -> f.includes(RESULT_FIELDS) } }
.query(query(request))
.sort(sort(request.sort))
}
fun query(request: UserSearchRequest): Query = Query.of { q ->
q.bool { b ->
request.name?.let { name ->
b.must { m -> m.match { mt -> mt.field("fullName").query(name).operator(Operator.And) } }
}
filters(request).forEach { b.filter(it) }
b
}
}
/** Exact constraints: filter context, no scoring. */
fun filters(request: UserSearchRequest): List<Query> = buildList {
add(term("accountStatus", request.accountStatus.name))
request.city?.let { add(term("city", it)) }
request.state?.let { add(term("state", it)) }
request.pincode?.let { add(term("pincode", it)) }
request.gender?.let { add(term("gender", it.name)) }
request.updatedSince?.let { since ->
add(Query.of { q -> q.range { r -> r.untyped { u -> u.field("updatedAt").gte(JsonData.of(since.toString())) } } })
}
}
/** Relevance first when requested, then newest, then userId so every order is total. */
fun sort(order: SortOrder): List<SortOptions> = buildList {
if (order == SortOrder.RELEVANCE) add(SortOptions.of { it.score { sc -> sc.order(EsSortOrder.Desc) } })
add(SortOptions.of { it.field { f -> f.field("updatedAt").order(EsSortOrder.Desc) } })
add(SortOptions.of { it.field { f -> f.field("userId").order(EsSortOrder.Asc) } })
}
private fun term(field: String, value: String): Query =
Query.of { q -> q.term { t -> t.field(field).value(FieldValue.of(value)) } }
}

Four choices here carry over into every later version. Exact constraints go into filter clauses, which do not affect scoring. RESULT_FIELDS limits _source to what a result list shows. Every sort ends with userId, so the order is total and repeatable, which chapter 13’s cursors depend on. And the target is always the read alias.

The service executes requests and maps responses into the contract:

search-api/src/main/kotlin/in/o612/eng/usersearch/api/search/UserSearchService.kt
package `in`.o612.eng.usersearch.api.search
import co.elastic.clients.elasticsearch.ElasticsearchClient
import co.elastic.clients.elasticsearch._types.FieldValue
import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation
import `in`.o612.eng.usersearch.api.web.ProfileDetail
import `in`.o612.eng.usersearch.api.web.TotalHits
import `in`.o612.eng.usersearch.api.web.UserSearchHit
import `in`.o612.eng.usersearch.api.web.UserSearchRequest
import `in`.o612.eng.usersearch.api.web.UserSearchResponse
import `in`.o612.eng.usersearch.index.UserProfileIndex
import org.springframework.stereotype.Service
import java.time.Instant
@Service
class UserSearchService(private val client: ElasticsearchClient) {
fun search(request: UserSearchRequest): UserSearchResponse {
val response = client.search(UserQueryBuilder.build(request), ResultSource::class.java)
val total = response.hits().total()
return UserSearchResponse(
total = TotalHits(total?.value() ?: 0, exact = total?.relation() == TotalHitsRelation.Eq),
hits = response.hits().hits().mapNotNull { hit ->
hit.source()?.let { src ->
UserSearchHit(
userId = src.userId,
fullName = src.fullName,
city = src.city,
state = src.state,
accountStatus = src.accountStatus,
updatedAt = src.updatedAt,
score = hit.score(),
highlights = hit.highlight(),
)
}
},
)
}
fun findById(userId: String): ProfileDetail? =
client.get({ it.index(UserProfileIndex.READ_ALIAS).id(userId).sourceIncludes(DETAIL_FIELDS) }, DetailSource::class.java)
.source()?.toDetail()
fun findByEmail(email: String): ProfileDetail? = findOneByTerm("email", email)
fun findByMobile(mobileNumber: String): ProfileDetail? = findOneByTerm("mobileNumber", mobileNumber)
/** Exact lookup on a keyword field. PostgreSQL enforces uniqueness; Elasticsearch cannot. */
private fun findOneByTerm(field: String, value: String): ProfileDetail? =
client.search({ s ->
s.index(UserProfileIndex.READ_ALIAS)
.size(1)
.source { src -> src.filter { f -> f.includes(DETAIL_FIELDS) } }
.query { q -> q.term { t -> t.field(field).value(FieldValue.of(value)) } }
}, DetailSource::class.java).hits().hits().firstOrNull()?.source()?.toDetail()
/** Source fields read for result lists. */
data class ResultSource(
val userId: String,
val fullName: String,
val city: String,
val state: String,
val accountStatus: String,
val updatedAt: Instant,
)
/** Source fields read for exact lookups. */
data class DetailSource(
val userId: String,
val fullName: String,
val email: String,
val mobileNumber: String,
val city: String,
val state: String,
val pincode: String,
val dateOfBirth: java.time.LocalDate?,
val gender: String,
val accountStatus: String,
val updatedAt: Instant,
) {
fun toDetail() = ProfileDetail(
userId, fullName, email, mobileNumber, city, state, pincode, dateOfBirth, gender, accountStatus, updatedAt,
)
}
private companion object {
val DETAIL_FIELDS = listOf(
"userId", "fullName", "email", "mobileNumber", "city", "state", "pincode",
"dateOfBirth", "gender", "accountStatus", "updatedAt",
)
}
}

Exact lookup by ID uses a get, which goes to one shard. Lookups by email and mobile number use a term query with size(1): those fields are unique in PostgreSQL, but Elasticsearch has no way to enforce uniqueness, so the code takes the first hit rather than assuming exactly one.

The controller exposes four endpoints and validates path and query parameters:

search-api/src/main/kotlin/in/o612/eng/usersearch/api/web/UserSearchController.kt
package `in`.o612.eng.usersearch.api.web
import `in`.o612.eng.usersearch.api.search.UserSearchService
import jakarta.validation.Valid
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Pattern
import org.springframework.http.ResponseEntity
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/users")
@Validated
class UserSearchController(private val service: UserSearchService) {
@GetMapping("/search")
fun search(@Valid request: UserSearchRequest): UserSearchResponse = service.search(request)
@GetMapping("/{userId}")
fun byId(@PathVariable @Pattern(regexp = "\\d{1,19}") userId: String): ResponseEntity<ProfileDetail> =
ResponseEntity.ofNullable(service.findById(userId))
@GetMapping("/by-email")
fun byEmail(@RequestParam @Email email: String): ResponseEntity<ProfileDetail> =
ResponseEntity.ofNullable(service.findByEmail(email))
@GetMapping("/by-mobile")
fun byMobile(@RequestParam @Pattern(regexp = "\\d{10}") mobileNumber: String): ResponseEntity<ProfileDetail> =
ResponseEntity.ofNullable(service.findByMobile(mobileNumber))
}

Failures from Elasticsearch are mapped to responses that say what happened without exposing details:

search-api/src/main/kotlin/in/o612/eng/usersearch/api/web/ErrorHandling.kt
package `in`.o612.eng.usersearch.api.web
import co.elastic.clients.elasticsearch._types.ElasticsearchException
import co.elastic.clients.transport.TransportException
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import java.io.IOException
@RestControllerAdvice
class ErrorHandling {
private val log = LoggerFactory.getLogger(javaClass)
/** Elasticsearch answered with an error: a request this service built is wrong, or the index is missing. */
@ExceptionHandler(ElasticsearchException::class)
fun elasticsearchError(e: ElasticsearchException): ProblemDetail {
log.error("Elasticsearch rejected a request: {} {}", e.status(), e.error().type(), e)
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_GATEWAY, "Search is temporarily failing.")
}
/** Elasticsearch could not be reached, timed out, or returned an unreadable response. */
@ExceptionHandler(TransportException::class, IOException::class)
fun unavailable(e: Exception): ProblemDetail {
log.warn("Search backend unavailable: {}", e.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, "Search is temporarily unavailable.")
}
}

An ElasticsearchException means the cluster answered with an error, such as a missing index or a query it cannot parse. That is this service’s fault or a deployment problem, so it maps to 502 and is logged as an error. A TransportException or IOException means the cluster could not be reached, timed out, or returned something unreadable, which maps to 503, a signal that retrying later may work.

Stage 6 — Run and verify

Start the service against the lab. The CA path must be absolute, with a file: prefix:

Terminal window
set -a; source ../user-search-lab/.env; set +a
export ELASTICSEARCH_URIS=https://localhost:9200
export ELASTICSEARCH_USERNAME=elastic ELASTICSEARCH_PASSWORD="$ELASTIC_PASSWORD"
export ELASTICSEARCH_CA_CERT="file:$(cd ../user-search-lab && pwd)/certs/ca/ca.crt"
./gradlew :search-api:bootRun

In a second terminal, search by name within a state. state=bihar is lowercase; the normaliser makes it match.

Terminal window
curl -s 'localhost:8080/api/users/search?name=prashant%20kumar&state=bihar&size=2'
{
"total": { "value": 237, "exact": true },
"hits": [
{ "userId": "835200", "fullName": "Prashant Kumar", "city": "Patna", "state": "Bihar",
"accountStatus": "ACTIVE", "updatedAt": "2025-11-03T00:00:00Z", "score": 6.396736, "highlights": {} },
{ "userId": "343200", "fullName": "Prashant Kumar", "city": "Patna", "state": "Bihar",
"accountStatus": "ACTIVE", "updatedAt": "2025-10-29T00:00:00Z", "score": 6.396736, "highlights": {} }
]
}

The response is reformatted here. Every hit has the same score, so the updatedAt tiebreaker decides the order. Filter without a name, newest first:

Terminal window
curl -s 'localhost:8080/api/users/search?state=bihar&sort=UPDATED_AT&updatedSince=2026-08-01T00:00:00Z&size=2'
{
"total": { "value": 78, "exact": true },
"hits": [
{ "userId": "560997", "fullName": "Ananya Jha", "city": "Patna", "state": "Bihar",
"accountStatus": "ACTIVE", "updatedAt": "2026-08-28T00:00:00Z", "score": null, "highlights": {} },
{ "userId": "196496", "fullName": "Sanjay Mishra", "city": "Patna", "state": "Bihar",
"accountStatus": "ACTIVE", "updatedAt": "2026-08-26T00:00:00Z", "score": null, "highlights": {} }
]
}

PostgreSQL agrees: 78 active profiles in Bihar were updated on or after 1 August 2026. The score is null because a filter-only query sorted by a field computes no scores.

Exact lookups return the full detail, including contact fields:

Terminal window
curl -s localhost:8080/api/users/42
{
"userId": "42", "fullName": "Suresh Sharma", "email": "suresh.sharma.42@example.com",
"mobileNumber": "9000000042", "city": "Gaya", "state": "Rajasthan", "pincode": "302068",
"dateOfBirth": "1985-02-13", "gender": "MALE", "accountStatus": "SUSPENDED",
"updatedAt": "2026-09-25T20:00:49.833876Z"
}

Profile 42’s updatedAt is the moment you ran chapter 04’s UPDATE, so yours differs. GET /api/users/43 returns 404, because chapter 04 deleted it. GET /api/users/by-email?email=PRIYA.KUMAR.1@EXAMPLE.COM returns profile 1, and GET /api/users/by-mobile?mobileNumber=9000000001 returns Priya Kumar.

Requests outside the contract are rejected before they reach Elasticsearch:

Terminal window
curl -s 'localhost:8080/api/users/search?size=500&pincode=12'
{"timestamp":"2026-09-25T20:21:52.570Z","status":400,"error":"Bad Request","path":"/api/users/search"}

The service log names both violations: size must be at most 50, and pincode must match \d{6}.

Finally, check the failure path. Start a second instance pointing at a port where nothing listens:

Terminal window
ELASTICSEARCH_URIS=https://localhost:9201 ./gradlew :search-api:bootRun --args='--server.port=8081'
curl -s 'localhost:8081/api/users/search?name=prashant'
{"detail":"Search is temporarily unavailable.","instance":"/api/users/search","status":503,"title":"Service Unavailable"}

The log line behind it reads Search backend unavailable: Connect to https://localhost:9201 [localhost/127.0.0.1] failed: Connection refused.

Checkpoint

Four things should now be true: the lab refuses plain HTTP, search-api starts with the CA from ELASTICSEARCH_CA_CERT, the name search returns 237 exact results, and an unreachable cluster produces 503. If startup fails with a certificate error, check that ELASTICSEARCH_CA_CERT has the file: prefix and an absolute path.

Security note — The Search API has no authentication yet, and its exact-lookup endpoints return email addresses, mobile numbers, and dates of birth. Bind it to localhost or keep it on a private network until chapter 16, which adds caller authentication, field-level access rules, and log masking. Elasticsearch cannot make these decisions for you: its security controls apply to the service account, not to the person using your API.

What you built, and what comes next

The lab now speaks only TLS. The user-search project has a Search API that reads through user-profile-read with a verified certificate, validates every request against the contract, returns totals with their exactness, keeps contact fields out of result lists, and maps cluster failures to 502 and 503. The shared search-index module can create an index version and its synonyms from reviewed files on an empty cluster.

What the service does not have yet is good search. It matches names with a single match query, ignores autocomplete and typos, and returns no highlights. Chapter 11 designs the query: query and filter context, bool clauses, scoring and when to ignore it, highlighting, and the complete “active users named Prashant in Bihar, updated recently” request in JSON and in Kotlin.

ElasticsearchSpring BootKotlin

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind