← Back to the section

Elasticsearch is a powerful search engine, but writing HTTP requests to its JSON API by hand means a lot of boilerplate. Spring Data Elasticsearch takes that work off your hands: you describe the document structure with a Java class, and you handle search and indexing with the usual Spring methods.

Let's go step by step: how to connect it, how to describe a document, how to search, and how to keep the index in sync with your main database.

Connecting

Add the dependency to build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
}

And configure the cluster address in application.properties:

spring.elasticsearch.uris=http://elasticsearch:9200
spring.elasticsearch.username=elastic
spring.elasticsearch.password=${ES_PASSWORD}
spring.elasticsearch.connection-timeout=2s
spring.elasticsearch.socket-timeout=10s

Spring Boot automatically creates a RestClient (an HTTP client to Elasticsearch), builds ElasticsearchOperations on top of it for convenient queries, and scans your packages for ElasticsearchRepository interfaces, which it implements on the fly.

How to describe a document with @Document

In JPA you describe a table with @Entity. In Elasticsearch, @Document plays a similar role — it links a Java class to a specific index.

@Document(indexName = "products")
public class ProductDoc {

    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "russian")
    private String name;

    @MultiField(
        mainField = @Field(type = FieldType.Text, analyzer = "russian"),
        otherFields = {
            @InnerField(suffix = "raw", type = FieldType.Keyword)
        }
    )
    private String description;

    @Field(type = FieldType.Long)
    private Long categoryId;

    @Field(type = FieldType.ScaledFloat, scalingFactor = 100)
    private BigDecimal price;

    @Field(type = FieldType.Boolean)
    private Boolean inStock;

    @Field(type = FieldType.Date, format = DateFormat.date_time)
    private Instant createdAt;

    // getters and setters
}

A few important details:

  • @Field(type = FieldType.Text, analyzer = "russian") — a full-text search field with a Russian-language analyzer (stemming, stop words).
  • @MultiField — one field in two variants: Text for meaning-based search and Keyword (suffix .raw) for exact filters and sorting.
  • @ScaledFloat — the recommended type for prices; it stores the number as an integer with a scale (100 → cents).
  • This is not JPA: there is no @Entity, @Table, or Hibernate transactions here. The class exists only to map the ES document.

If you need fine-grained control over the mapping, point to a ready-made JSON file: @Mapping(mappingPath = "elasticsearch/product-mapping.json").

Simple queries with ElasticsearchRepository

The fastest way to get started is to declare a repository interface:

public interface ProductRepository extends ElasticsearchRepository<ProductDoc, String> {

    Page<ProductDoc> findByCategoryId(Long categoryId, Pageable pageable);

    List<ProductDoc> findByNameContainingAndInStockTrue(String namePart);

    long countByPriceBetween(BigDecimal min, BigDecimal max);
}

Spring parses the method names and generates the Query DSL automatically. This works for simple cases: filters by exact values, sorting, and pagination.

When the repository is no longer enough:

  • Method names grow to 8–10 words and become hard to read.
  • There's no control over fuzzy search (fuzziness), field boosts, or aggregations.
  • You can't write a complex bool query with several conditions.

For those cases, there's ElasticsearchOperations.

Flexible queries with ElasticsearchOperations

ElasticsearchOperations is a lower-level API that gives you full control over queries:

@Service
@RequiredArgsConstructor
public class ProductSearchService {

    private final ElasticsearchOperations elasticsearch;

    public SearchHits<ProductDoc> search(String text, Set<Long> categories,
                                          BigDecimal minPrice, BigDecimal maxPrice,
                                          Pageable pageable) {
        var criteria = new Criteria("name").matches(text)
            .and(new Criteria("inStock").is(true));

        if (!categories.isEmpty()) {
            criteria = criteria.and(new Criteria("categoryId").in(categories));
        }
        if (minPrice != null) {
            criteria = criteria.and(new Criteria("price").greaterThanEqual(minPrice));
        }
        if (maxPrice != null) {
            criteria = criteria.and(new Criteria("price").lessThanEqual(maxPrice));
        }

        var query = new CriteriaQuery(criteria, pageable);
        return elasticsearch.search(query, ProductDoc.class);
    }
}

When even CriteriaQuery isn't enough — for example, when you need aggregations or a date decay function — you use NativeQuery. This is practically raw JSON, but with type checking at compile time:

public SearchHits<ProductDoc> searchWithFunctionScore(String text) {
    var query = NativeQuery.builder()
        .withQuery(q -> q
            .functionScore(fs -> fs
                .query(qq -> qq.match(m -> m.field("name").query(text)))
                .functions(f -> f
                    .gauss(g -> g
                        .field("createdAt")
                        .placement(p -> p.origin("now").scale("30d").decay(0.5))))
            ))
        .build();
    return elasticsearch.search(query, ProductDoc.class);
}

Bulk indexing

Indexing documents one at a time is slow: each call is a separate HTTP request plus waiting for confirmation. That's not how you load a large catalog.

The Bulk API lets you send thousands of documents in a single request:

@Service
@RequiredArgsConstructor
public class ProductIndexer {

    private final ElasticsearchOperations elasticsearch;

    public void reindexAll(List<ProductDoc> docs) {
        var queries = docs.stream()
            .map(doc -> new IndexQueryBuilder()
                .withId(doc.getId())
                .withObject(doc)
                .build())
            .toList();

        elasticsearch.bulkIndex(queries, ProductDoc.class);
    }
}

For very large volumes (millions of documents), split them into batches of 500–5000 and temporarily turn off automatic index refresh (refresh_interval) so you don't waste resources on intermediate versions:

public void bulkReindex(List<ProductDoc> docs) {
    var indexOps = elasticsearch.indexOps(ProductDoc.class);

    indexOps.putSettings(Map.of("index.refresh_interval", "-1"));

    try {
        Lists.partition(docs, 5000).forEach(this::reindexAll);
    } finally {
        indexOps.putSettings(Map.of("index.refresh_interval", "1s"));
        indexOps.refresh();
    }
}

The speedup compared to one-at-a-time indexing is 10–50x.

How to keep the index up to date: four approaches

In most applications, Elasticsearch is not the main database but a search index sitting next to PostgreSQL. Data appears in PostgreSQL, and you need to reflect it in ES promptly. There are four ways to organize this.

Dual write — simple, but unreliable

The most obvious option: write to PostgreSQL and to ES in a single method.

@Transactional
public void save(Product product) {
    productRepo.save(product);              // PostgreSQL
    elasticsearch.save(toDoc(product));     // Elasticsearch
}

The problem: a PostgreSQL transaction and an HTTP request to ES are two different operations. If PostgreSQL commits but ES returns a network error, the data diverges, and it's unclear how to bring it back in sync. Suitable only for prototypes, not for production.

Transactional Outbox — reliable, but needs infrastructure

Inside the PostgreSQL transaction, along with the business data, we write an event to a dedicated outbox table. A separate reader process takes events from that table and sends them to ES.

@Transactional
public void save(Product product) {
    productRepo.save(product);
    outboxRepo.save(new OutboxEvent(
        UUID.randomUUID(),
        "product.updated",
        toJson(product)
    ));
}
@Scheduled(fixedDelay = 1000)
@Transactional
public void publishToEs() {
    var batch = outboxRepo.fetchUnpublished(500);
    for (var event : batch) {
        var doc = parseProductDoc(event.payload());
        elasticsearch.save(doc);
        outboxRepo.markPublished(event.id());
    }
}

Pros: the data won't diverge — the event is written atomically together with the business data; if ES fails, the event stays unread and will be retried. Cons: you need an outbox table, reader logic, and lag monitoring.

More on this pattern in the Distributed patterns section.

CDC via Debezium → Kafka → ES — the industrial option

Change Data Capture (CDC) is an approach in which your service knows nothing about Elasticsearch at all. It simply writes to PostgreSQL.

PostgreSQL  →  Debezium  →  Kafka  →  Kafka Connect ES Sink  →  Elasticsearch
  (WAL)

Debezium reads the PostgreSQL transaction log (WAL) and publishes each change as an event in Kafka. The Elasticsearch Sink connector reads these events and inserts/updates/deletes documents in ES.

Advantages: the service isn't tied to ES; all changes are captured, including direct edits in the database; if it fails, the connector resumes from its last position. The indexing latency is usually 100–500 milliseconds. The downside is that you need to deploy and maintain Debezium, Kafka, and Kafka Connect.

Full reindex on a schedule

If the data changes rarely (reference data, catalogs), you can simply rebuild the entire index at night:

@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Moscow")
public void reindexCatalog() {
    var newIndex = "products-v" + System.currentTimeMillis();
    elasticsearch.indexOps(IndexCoordinates.of(newIndex)).create();

    productRepo.findAll().forEach(product ->
        elasticsearch.save(toDoc(product), IndexCoordinates.of(newIndex))
    );

    // switch the alias: queries to "products" now go to the new index
    elasticsearch.indexOps(IndexCoordinates.of("products")).alias(
        new AliasActions().add(new AliasAction.Add(
            AliasActionParameters.builderForAdd()
                .withIndices(newIndex).withAliases("products").build()
        ))
    );
}

Simple to implement and maintain. Not suitable when updates need to appear in search in real time.

Aliases — switching indices without downtime

An Elasticsearch schema can't be changed in place: if you need to rename a field or change a type, you have to create a new index and copy the data over. To keep the application running during that, you use aliases.

An alias is a pointer to an index. The application refers to the products alias without knowing which index actually sits behind it.

# create an index and bind an alias to it
PUT /products-v1
POST /_aliases
{
  "actions": [
    { "add": { "index": "products-v1", "alias": "products" } }
  ]
}

# when you need to change the schema: create products-v2, copy the data, switch
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products-v1", "alias": "products" } },
    { "add":    { "index": "products-v2", "alias": "products" } }
  ]
}

Switching an alias is atomic: at one moment all queries move to the new index without any errors on the application side.

Common pitfalls

Elasticsearch is not transactional

elasticsearch.save(doc) saves the document immediately and does not roll back together with the PostgreSQL transaction. This is a fundamental limitation, and all the synchronization approaches above are built precisely around it.

A document is not visible immediately after saving

By default, ES refreshes the search index once per second. If, in an integration test, you search for a document right after saving it, it may not have appeared yet. The solution for tests:

elasticsearch.save(doc, RefreshPolicy.WAIT_UNTIL);  // wait for the index refresh

In production this is expensive — there it's better to accept the latency as a given.

The client version and the cluster version must match

The elasticsearch-java client version 8.x works with an 8.x cluster. When you upgrade the cluster, upgrade the client too.

Field types must be set correctly from the start

If you describe the categoryId field as Keyword instead of Long, you'll have to pass it as a string. You can't change the type later without recreating the index. Set the correct types from the start.

Document size

Elasticsearch handles documents larger than 10 MB poorly. If you need to store large texts, split them into parts or keep them in separate storage, leaving only the searchable fields in ES.

In short

  • spring-boot-starter-data-elasticsearch automatically configures the client and repositories.
  • @Document describes the index, @Field describes field types; @MultiField gives you a field in two variants for search and exact filtering.
  • ElasticsearchRepository is good for simple method-name queries; ElasticsearchOperations is for complex queries with full control.
  • The Bulk API is 10–50x faster than one-at-a-time indexing; for large volumes, temporarily turn off refresh_interval.
  • Dual write is unreliable; for production, use Transactional Outbox or CDC via Debezium.
  • Use aliases instead of direct index names — this lets you change the schema without downtime.
  • After saving, a document becomes visible in search after about a second — account for this in tests.
  • Fundamentals — how the index works, shards, replication.
  • Query DSL and relevance — what Spring Data Elasticsearch generates under the hood.
  • Operations — ILM, snapshots, cluster tuning.
  • Distributed patterns — more on Outbox and CDC.