Questa guida mostra un flusso completo e riproducibile per integrare Elasticsearch in una web application Spring Boot, eseguendo tutto in locale con Docker e Docker Compose. Partiremo da un caso d'uso realistico (catalogo prodotti), configurando indice e mapping, ingestione dati, ricerca full-text e filtri, paginazione e sorting, osservabilità e alcune considerazioni per ambienti di produzione.
Prerequisiti
- JDK 21 (o 17) installato
- Docker e Docker Compose
- Un editor e Maven (o Gradle)
Approccio: REST Client di Elasticsearch vs Spring Data Elasticsearch
In Spring Boot puoi interrogare Elasticsearch in due modi principali:
- Elasticsearch Java API Client (client ufficiale, basato su REST): massimo controllo sul DSL, consigliato per casi avanzati.
- Spring Data Elasticsearch: repository e query derivate, più comodo, ma meno flessibile su mapping e query complesse.
In questo articolo useremo Spring Data Elasticsearch per partire velocemente, e affiancheremo esempi con il Java API Client quando serve più controllo sul DSL.
Struttura del progetto
Creiamo un progetto Maven (puoi adattare a Gradle) con questa struttura:
spring-es-demo/
docker-compose.yml
.env
app/
Dockerfile
src/
main/
java/com/example/demo/...
resources/application.yml
pom.xml
Docker Compose: Elasticsearch + Kibana + applicazione
In locale conviene avviare un cluster single-node, con una memoria configurata e un healthcheck affidabile. Usiamo anche Kibana per ispezionare indice e query.
File docker-compose.yml
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.12.2
container_name: es01
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms1g -Xmx1g
- bootstrap.memory_lock=true
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata:/usr/share/elasticsearch/data
ports:
- "9200:9200"
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=60s | grep -q '\"status\"'"]
interval: 10s
timeout: 5s
retries: 12
kibana:
image: docker.elastic.co/kibana/kibana:8.12.2
container_name: kb01
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
ports:
- "5601:5601"
depends_on:
elasticsearch:
condition: service_healthy
app:
build:
context: .
dockerfile: app/Dockerfile
container_name: spring-app
environment:
- SPRING_PROFILES_ACTIVE=docker
- ELASTICSEARCH_URL=http://elasticsearch:9200
ports:
- "8080:8080"
depends_on:
elasticsearch:
condition: service_healthy
volumes:
esdata:
Nota: per semplicità in locale disabilitiamo la security di Elasticsearch (xpack.security.enabled=false).
In produzione è fondamentale abilitarla e usare TLS e credenziali.
Variabili d'ambiente opzionali
Puoi mettere variabili in un file .env (Compose le carica automaticamente):
SPRING_PROFILES_ACTIVE=docker
ELASTICSEARCH_URL=http://elasticsearch:9200
Dockerfile per Spring Boot
Usiamo un build multi-stage. Il primo stage compila con Maven, il secondo esegue un JRE leggero.
File app/Dockerfile
# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -q -DskipTests package
# Stage 2: run
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]
Dipendenze Maven
Per Spring Data Elasticsearch aggiungi lo starter. Aggiungiamo anche validation e web.
File pom.xml (estratto)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
Configurazione Spring Boot
Definiamo un profilo docker che legge l'URL da variabile d'ambiente.
Spring Boot 3 configura automaticamente il client a partire da spring.elasticsearch.uris.
File src/main/resources/application.yml
spring:
application:
name: spring-es-demo
elasticsearch:
uris: ${ELASTICSEARCH_URL:http://localhost:9200}
management:
endpoints:
web:
exposure:
include: health,info,metrics
---
spring:
config:
activate:
on-profile: docker
# Qui puoi aggiungere override specifici per docker, se necessario.
Modello indicizzato: documento e mapping
In Elasticsearch pensiamo in termini di indice (index) e documenti. Il mapping definisce tipi di campo, analyzer e opzioni. Spring Data permette di annotare un POJO come documento.
Classe documento ProductDocument
package com.example.demo.search;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
@Document(indexName = "products")
@Setting(settingPath = "/es/products-settings.json")
@Mapping(mappingPath = "/es/products-mapping.json")
public class ProductDocument {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "it_text")
private String name;
@Field(type = FieldType.Text, analyzer = "it_text")
private String description;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Keyword)
private List<String> categories;
@Field(type = FieldType.Double)
private BigDecimal price;
@Field(type = FieldType.Date)
private Instant createdAt;
public ProductDocument() {}
// getter/setter omessi per brevità
}
Notare @Setting e @Mapping: ci permettono di versionare impostazioni e mapping come file JSON,
controllando analyzer, normalizer, etc. Questo è preferibile a lasciare tutto al mapping dinamico.
Settings: analyzer italiano semplificato
Crea src/main/resources/es/products-settings.json:
{
"analysis": {
"analyzer": {
"it_text": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding"]
}
}
}
}
Mapping: campi, keyword e full-text
Crea src/main/resources/es/products-mapping.json:
{
"properties": {
"id": { "type": "keyword" },
"name": { "type": "text", "analyzer": "it_text" },
"description": { "type": "text", "analyzer": "it_text" },
"brand": { "type": "keyword" },
"categories": { "type": "keyword" },
"price": { "type": "double" },
"createdAt": { "type": "date" }
}
}
Creazione indice all'avvio
In ambienti reali è comune applicare migrazioni (versionamento mapping) tramite pipeline dedicate. In locale e in demo, però, è utile creare l'indice automaticamente se manca.
Initializer
package com.example.demo.search;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.IndexOperations;
@Configuration
public class ElasticsearchIndexConfig {
@Bean
ApplicationRunner createIndexIfMissing(ElasticsearchOperations operations) {
return args -> {
IndexOperations indexOps = operations.indexOps(ProductDocument.class);
if (!indexOps.exists()) {
indexOps.create();
indexOps.putMapping(indexOps.createMapping(ProductDocument.class));
}
};
}
}
Se usi @Setting e @Mapping, Spring Data può applicare file di mapping e settings
durante indexOps.create(). In alternativa puoi creare indice e mapping via script o tool esterni.
Repository Spring Data
Definiamo un repository per operazioni base e qualche query derivata.
ProductSearchRepository
package com.example.demo.search;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
public interface ProductSearchRepository extends ElasticsearchRepository<ProductDocument, String> {
Page<ProductDocument> findByBrand(String brand, Pageable pageable);
Page<ProductDocument> findByCategoriesIn(List<String> categories, Pageable pageable);
}
Le query derivate sono comode, ma per ricerche full-text complesse è meglio usare
ElasticsearchOperations con query native.
Ricerca full-text e filtri con ElasticsearchOperations
Implementiamo una ricerca che combina:
- multi_match su
nameedescription - filtri per brand e categorie
- range di prezzo
- paginazione e sorting
DTO di input per la ricerca
package com.example.demo.api;
import jakarta.validation.constraints.Min;
public class ProductSearchRequest {
public String q;
public String brand;
public String category;
@Min(0)
public Double minPrice;
@Min(0)
public Double maxPrice;
@Min(0)
public int page = 0;
@Min(1)
public int size = 10;
public String sort = "relevance"; // relevance | price_asc | price_desc | newest
}
Service di ricerca
package com.example.demo.search;
import com.example.demo.api.ProductSearchRequest;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.NativeQuery;
import org.springframework.stereotype.Service;
import static org.elasticsearch.index.query.QueryBuilders.*;
@Service
public class ProductSearchService {
private final ElasticsearchOperations operations;
public ProductSearchService(ElasticsearchOperations operations) {
this.operations = operations;
}
public SearchHits<ProductDocument> search(ProductSearchRequest req) {
var bool = boolQuery();
if (req.q != null && !req.q.isBlank()) {
bool.must(multiMatchQuery(req.q, "name", "description")
.type(org.elasticsearch.index.query.MultiMatchQueryBuilder.Type.BEST_FIELDS));
} else {
bool.must(matchAllQuery());
}
if (req.brand != null && !req.brand.isBlank()) {
bool.filter(termQuery("brand", req.brand));
}
if (req.category != null && !req.category.isBlank()) {
bool.filter(termQuery("categories", req.category));
}
if (req.minPrice != null || req.maxPrice != null) {
var range = rangeQuery("price");
if (req.minPrice != null) range.gte(req.minPrice);
if (req.maxPrice != null) range.lte(req.maxPrice);
bool.filter(range);
}
var pageable = PageRequest.of(req.page, req.size, resolveSort(req.sort));
NativeQuery query = NativeQuery.builder()
.withQuery(bool)
.withPageable(pageable)
.build();
return operations.search(query, ProductDocument.class);
}
private Sort resolveSort(String sort) {
if (sort == null) return Sort.unsorted();
return switch (sort) {
case "price_asc" -> Sort.by(Sort.Order.asc("price"));
case "price_desc" -> Sort.by(Sort.Order.desc("price"));
case "newest" -> Sort.by(Sort.Order.desc("createdAt"));
default -> Sort.unsorted(); // relevance: sort naturale per score
};
}
}
Per la relevancy, Elasticsearch ordina per _score quando non specifichi un sort esplicito.
Se combini sort e relevancy, considera l'uso di function_score o di un rank feature,
ma è un tema avanzato.
Endpoint REST per indicizzazione e ricerca
A livello applicativo spesso esiste una sorgente di verità (DB relazionale) e si indicizza su Elasticsearch per accelerare la ricerca. In questa demo creiamo endpoint semplici per caricare documenti e cercare.
DTO di risposta per la ricerca
package com.example.demo.api;
import java.util.List;
public class ProductSearchResponse {
public long total;
public List<ProductHit> items;
public static class ProductHit {
public String id;
public String name;
public String description;
public String brand;
public List<String> categories;
public double price;
}
}
Controller
package com.example.demo.api;
import java.util.List;
import com.example.demo.search.ProductDocument;
import com.example.demo.search.ProductSearchRepository;
import com.example.demo.search.ProductSearchService;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/products")
@Validated
public class ProductController {
private final ProductSearchRepository repository;
private final ProductSearchService searchService;
public ProductController(ProductSearchRepository repository, ProductSearchService searchService) {
this.repository = repository;
this.searchService = searchService;
}
@PostMapping("/index")
@ResponseStatus(HttpStatus.CREATED)
public Iterable<ProductDocument> index(@RequestBody List<ProductDocument> docs) {
return repository.saveAll(docs);
}
@GetMapping("/search")
public ProductSearchResponse search(@Valid ProductSearchRequest req) {
var hits = searchService.search(req);
ProductSearchResponse resp = new ProductSearchResponse();
resp.total = hits.getTotalHits();
resp.items = hits.getSearchHits().stream().map(this::toHit).toList();
return resp;
}
private ProductSearchResponse.ProductHit toHit(SearchHit<ProductDocument> hit) {
var d = hit.getContent();
var out = new ProductSearchResponse.ProductHit();
out.id = d.getId();
out.name = d.getName();
out.description = d.getDescription();
out.brand = d.getBrand();
out.categories = d.getCategories();
out.price = d.getPrice().doubleValue();
return out;
}
}
Esempio di dati da indicizzare
Puoi inviare un payload JSON a POST /api/products/index:
[
{
"id": "p1",
"name": "Cuffie wireless",
"description": "Cuffie con cancellazione del rumore e autonomia di 30 ore",
"brand": "AcmeAudio",
"categories": ["audio", "cuffie"],
"price": 129.99,
"createdAt": "2025-12-15T10:00:00Z"
},
{
"id": "p2",
"name": "Mouse ergonomico",
"description": "Mouse verticale con sensore ad alta precisione",
"brand": "WorkPro",
"categories": ["periferiche", "mouse"],
"price": 49.90,
"createdAt": "2025-12-20T09:30:00Z"
}
]
Poi prova una ricerca:
/api/products/search?q=cancellazione+rumore&sort=price_desc&page=0&size=10.
Test rapido con curl
# Avvia tutto
docker compose up --build
# Indicizza (da un'altra shell)
curl -X POST "http://localhost:8080/api/products/index" -H "Content-Type: application/json" -d @products.json
# Cerca
curl "http://localhost:8080/api/products/search?q=mouse&page=0&size=10"
Debug e ispezione con Kibana
Kibana è esposto su http://localhost:5601. In Dev Tools puoi interrogare direttamente Elasticsearch.
Esempio di query equivalente:
GET products/_search
{
"query": {
"bool": {
"must": [
{ "multi_match": { "query": "mouse", "fields": ["name", "description"] } }
],
"filter": [
{ "term": { "brand": "WorkPro" } }
]
}
}
}
Gestione errori e resilienza
In produzione Elasticsearch può essere temporaneamente non disponibile o rallentare. Buone pratiche:
- usa timeouts lato client e circuit breaker (Resilience4j) per degradare in modo controllato
- non fare affidamento su Elasticsearch come sistema di verità; indicizza da un DB o da un event stream
- gestisci retry con backoff per ingestione e reindicizzazione
Esempio: gestione eccezioni (semplificato)
package com.example.demo.api;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public String generic(Exception ex) {
return "Errore interno: " + ex.getMessage();
}
}
Uso del client ufficiale (quando serve più controllo)
Se vuoi il DSL completo e tipizzato, puoi usare l'Elasticsearch Java API Client. In Spring Boot 3, lo starter di data-elasticsearch porta già un client, ma per configurazioni avanzate potresti creare un bean dedicato. Qui un esempio minimale di una ricerca con DSL tipizzato.
package com.example.demo.search;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import org.springframework.stereotype.Service;
@Service
public class LowLevelSearchService {
private final ElasticsearchClient client;
public LowLevelSearchService(ElasticsearchClient client) {
this.client = client;
}
public SearchResponse<ProductDocument> searchByName(String q) throws Exception {
return client.search(s -> s
.index("products")
.query(qb -> qb
.match(m -> m.field("name").query(q))
),
ProductDocument.class
);
}
}
Aggregazioni: faceting per categorie e brand
Un classico scenario e-commerce è mostrare conteggi per filtri (facets). In Elasticsearch si fa con aggregazioni. Esempio con client ufficiale:
public void exampleAgg(ElasticsearchClient client) throws Exception {
var resp = client.search(s -> s
.index("products")
.size(0)
.aggregations("by_brand", a -> a.terms(t -> t.field("brand")))
.aggregations("by_category", a -> a.terms(t -> t.field("categories"))),
ProductDocument.class
);
var byBrand = resp.aggregations().get("by_brand").sterms().buckets().array();
for (var b : byBrand) {
System.out.println(b.key().stringValue() + " - " + b.docCount());
}
}
Operazioni di reindicizzazione e migrazioni mapping
Modificare mapping su un indice esistente è limitato. In pratica si usa:
- un nuovo indice con versionamento (es.
products_v2) - reindex da
products_v1aproducts_v2 - switch di alias (es. alias
productsche punta al nuovo indice)
In questo modo l'app continua a interrogare l'alias mentre tu migri i dati.
Esempio concettuale (Kibana Dev Tools)
POST _reindex
{
"source": { "index": "products_v1" },
"dest": { "index": "products_v2" }
}
Ottimizzazioni Docker e risorse
In locale basta, ma su macchine con poca RAM potresti voler ridurre heap e limiti. Alcuni consigli:
- riduci
ES_JAVA_OPTS(es. 512m) se hai dataset piccoli - usa volumi per persistere i dati e accelerare i restart
- aggiungi
restart: unless-stoppedse vuoi più robustezza in ambienti di test
Sicurezza e produzione
Per un deployment reale considera almeno:
- abilitare security (TLS, utenti/ruoli) e configurare credenziali lato Spring
- mettere Elasticsearch dietro una rete privata; non esporre la porta 9200 pubblicamente
- monitoring (metriche, slow logs), alerting e snapshot/backup
- strategie di scaling (repliche, shard count, dimensionamento)
Configurazione credenziali (esempio, con security attiva)
spring:
elasticsearch:
uris: https://es-prod:9200
username: ${ES_USERNAME}
password: ${ES_PASSWORD}
Riepilogo
Hai costruito un ambiente completo con Docker Compose (Elasticsearch + Kibana + Spring Boot), definito mapping e analyzer, creato un repository e una ricerca full-text con filtri, paginazione e sorting. Da qui puoi evolvere verso indicizzazione asincrona da database o eventi, facets e suggerimenti, versionamento indici e alias per migrazioni senza downtime, security e hardening per produzione.
Pattern tipico consigliato: scrivi sul DB, pubblica un evento, e aggiorna l'indice Elasticsearch in modo idempotente, mantenendo la possibilità di ricostruire l'indice da zero (rebuild) quando cambi mapping o analyzer.