Come usare Elasticsearch in un'applicazione web con Java Spring Boot usando Docker e Docker Compose

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 name e description
  • 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_v1 a products_v2
  • switch di alias (es. alias products che 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-stopped se 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.

Torna su