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:
#!/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 pipefailCERTS=/usr/share/elasticsearch/config/certscd "$CERTS"
if [[ ! -f ca/ca.crt ]]; then elasticsearch-certutil ca --silent --pem --out "$CERTS/ca.zip" unzip -q ca.zip && rm ca.zipfi
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.zipfi
# 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.
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:
docker compose up -dWhen docker compose ps -a shows certs and kibana-setup as Exited (0) and the other services up, check the connection three ways:
set -a; source .env; set +acurl -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:
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.
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:
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: 2sfails fast when a node is unreachable.socket-timeout: 5scaps 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.elasticsearchloads the CA certificate fromELASTICSEARCH_CA_CERT, andspring.elasticsearch.restclient.ssl.bundleapplies 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
elasticsuperuser’s password inELASTICSEARCH_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 touser-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:
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:
{ "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:
package `in`.o612.eng.usersearch.index
import co.elastic.clients.elasticsearch.ElasticsearchClientimport co.elastic.clients.elasticsearch._types.ElasticsearchExceptionimport co.elastic.clients.elasticsearch.indices.CreateIndexRequestimport co.elastic.clients.elasticsearch.synonyms.PutSynonymRequestimport 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:
package `in`.o612.eng.usersearch.api.config
import co.elastic.clients.elasticsearch.ElasticsearchClientimport co.elastic.clients.json.JsonpMapperimport `in`.o612.eng.usersearch.index.ElasticsearchJsonimport `in`.o612.eng.usersearch.index.IndexAdminimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration
@Configurationclass 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:
package `in`.o612.eng.usersearch.api.index
import `in`.o612.eng.usersearch.index.IndexAdminimport `in`.o612.eng.usersearch.index.UserProfileIndeximport org.slf4j.LoggerFactoryimport org.springframework.boot.ApplicationArgumentsimport org.springframework.boot.ApplicationRunnerimport org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanPropertyimport 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: trueand 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 createStage 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.
package `in`.o612.eng.usersearch.api.web
import jakarta.validation.constraints.Maximport jakarta.validation.constraints.Minimport jakarta.validation.constraints.Patternimport jakarta.validation.constraints.Sizeimport java.time.Instantimport 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,)UserSearchRequestbinds from query parameters, with Bean Validation on each field.sizeis 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, andsort. Spring rejects any other value with a400, so the case-sensitive keyword filters from chapter 05 only ever receive valid values. accountStatusdefaults toACTIVE. A caller who forgets the filter does not see suspended or deleted profiles by accident.TotalHitscarriesexact. Chapter 09 showed that totals stop at 10,000 by default; the contract says which kind of number it is.UserSearchHithas no email, mobile number, or date of birth. OnlyProfileDetail, 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.
package `in`.o612.eng.usersearch.api.search
import co.elastic.clients.elasticsearch._types.FieldValueimport co.elastic.clients.elasticsearch._types.SortOptionsimport co.elastic.clients.elasticsearch._types.SortOrder as EsSortOrderimport co.elastic.clients.elasticsearch._types.query_dsl.Operatorimport co.elastic.clients.elasticsearch._types.query_dsl.Queryimport co.elastic.clients.elasticsearch.core.SearchRequestimport co.elastic.clients.json.JsonDataimport `in`.o612.eng.usersearch.api.web.SortOrderimport `in`.o612.eng.usersearch.api.web.UserSearchRequestimport `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:
package `in`.o612.eng.usersearch.api.search
import co.elastic.clients.elasticsearch.ElasticsearchClientimport co.elastic.clients.elasticsearch._types.FieldValueimport co.elastic.clients.elasticsearch.core.search.TotalHitsRelationimport `in`.o612.eng.usersearch.api.web.ProfileDetailimport `in`.o612.eng.usersearch.api.web.TotalHitsimport `in`.o612.eng.usersearch.api.web.UserSearchHitimport `in`.o612.eng.usersearch.api.web.UserSearchRequestimport `in`.o612.eng.usersearch.api.web.UserSearchResponseimport `in`.o612.eng.usersearch.index.UserProfileIndeximport org.springframework.stereotype.Serviceimport java.time.Instant
@Serviceclass 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:
package `in`.o612.eng.usersearch.api.web
import `in`.o612.eng.usersearch.api.search.UserSearchServiceimport jakarta.validation.Validimport jakarta.validation.constraints.Emailimport jakarta.validation.constraints.Patternimport org.springframework.http.ResponseEntityimport org.springframework.validation.annotation.Validatedimport org.springframework.web.bind.annotation.GetMappingimport org.springframework.web.bind.annotation.PathVariableimport org.springframework.web.bind.annotation.RequestMappingimport org.springframework.web.bind.annotation.RequestParamimport org.springframework.web.bind.annotation.RestController
@RestController@RequestMapping("/api/users")@Validatedclass 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:
package `in`.o612.eng.usersearch.api.web
import co.elastic.clients.elasticsearch._types.ElasticsearchExceptionimport co.elastic.clients.transport.TransportExceptionimport org.slf4j.LoggerFactoryimport org.springframework.http.HttpStatusimport org.springframework.http.ProblemDetailimport org.springframework.web.bind.annotation.ExceptionHandlerimport org.springframework.web.bind.annotation.RestControllerAdviceimport java.io.IOException
@RestControllerAdviceclass 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:
set -a; source ../user-search-lab/.env; set +aexport ELASTICSEARCH_URIS=https://localhost:9200export ELASTICSEARCH_USERNAME=elastic ELASTICSEARCH_PASSWORD="$ELASTIC_PASSWORD"export ELASTICSEARCH_CA_CERT="file:$(cd ../user-search-lab && pwd)/certs/ca/ca.crt"./gradlew :search-api:bootRunIn a second terminal, search by name within a state. state=bihar is lowercase; the normaliser makes it match.
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:
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:
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:
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:
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
localhostor 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.