Elasticsearch #

When a user types “cheap gaming laptop” into your online store’s search box, they expect relevant results to appear in milliseconds — not a slow, unintelligent SQL LIKE '%laptop%gaming%cheap%' query. Elasticsearch is a search engine based on Apache Lucene, designed specifically for this need: fast, relevant, and scalable full-text search. More than just a search engine, Elasticsearch is also used as an analytics engine for log aggregation, infrastructure monitoring, and business intelligence. This article covers how to use Elasticsearch in Java — from the basic inverted index concept, connection setup, document operations, various query types, aggregations, to Spring Data Elasticsearch integration.

Elasticsearch Basics #

Understanding how Elasticsearch stores and searches data explains why it can be so fast. Elasticsearch isn’t an ordinary database — it’s a search engine optimized for text search.

Terminology #

ConceptElasticsearchDescription
DatabaseIndexA container for a collection of documents of the same type
Table(doesn’t exist)ES doesn’t distinguish tables within an index
RowDocumentA single data entry in JSON format
ColumnFieldAn attribute within a document
SchemaMappingThe definition of each field’s data type
SQL QueryQuery DSLJSON-based query language
GROUP BYAggregationGrouping and calculating statistics

Inverted Index — Why Search Is Fast #

SQL databases store data row by row. When you query WHERE name LIKE '%laptop%', the database must read every row — slow for millions of records. Elasticsearch flips this logic with the inverted index.

flowchart TD
    subgraph "Input Documents"
        D1["Doc 1: 'Gaming Pro Laptop'"]
        D2["Doc 2: 'Business Slim Laptop'"]
        D3["Doc 3: 'Mechanical Gaming Keyboard'"]
    end

    subgraph "Analysis Process"
        A["Tokenizer\n(split into words)"]
        B["Filter\n(lowercase, stemming)"]
    end

    subgraph "Inverted Index"
        T1["'laptop' → Doc1, Doc2"]
        T2["'gaming' → Doc1, Doc3"]
        T3["'pro' → Doc1"]
        T4["'business' → Doc2"]
        T5["'slim' → Doc2"]
        T6["'keyboard' → Doc3"]
    end

    D1 --> A
    D2 --> A
    D3 --> A
    A --> B
    B --> T1
    B --> T2
    B --> T3
    B --> T4
    B --> T5
    B --> T6

When a user searches for “gaming laptop”, Elasticsearch immediately looks up the inverted index: laptop → Doc1, Doc2 and gaming → Doc1, Doc3. Their intersection: Doc1. No full scan — straight to the results.

Cluster Architecture #

flowchart TD
    Client["Java Application"] --> LB["Load Balancer / Client Node"]
    LB --> M["Master Node\n(cluster coordination)"]
    LB --> D1["Data Node 1\nShard 0 (Primary)\nShard 1 (Replica)"]
    LB --> D2["Data Node 2\nShard 1 (Primary)\nShard 0 (Replica)"]
    LB --> D3["Data Node 3\nShard 2 (Primary)\nShard 2 (Replica)"]
    M --> D1
    M --> D2
    M --> D3

An index in Elasticsearch is divided into shards (primaries) distributed across several nodes. Each primary shard has one or more replica shards on different nodes — for high availability and increased read throughput.


Installation and Dependencies #

The Elasticsearch Java Client 8.x (the newest official client) uses a different architecture from the old HLRC (High-Level REST Client), which is deprecated.

<!-- pom.xml — Elasticsearch Java Client 8.x -->
<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
    <version>8.11.0</version>
</dependency>

<!-- Jackson for JSON serialization -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.2</version>
</dependency>

<!-- Jakarta JSON API (required by the ES client) -->
<dependency>
    <groupId>jakarta.json</groupId>
    <artifactId>jakarta.json-api</artifactId>
    <version>2.1.1</version>
</dependency>

<!-- If using Spring Boot -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
// build.gradle
implementation 'co.elastic.clients:elasticsearch-java:8.11.0'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
implementation 'jakarta.json:jakarta.json-api:2.1.1'
Don’t use elasticsearch-rest-high-level-client (HLRC) for new projects. HLRC has been deprecated since Elasticsearch 7.15 and removed in version 8.x. Use elasticsearch-java (Java API Client) instead.

Connecting to Elasticsearch #

Connection Without Authentication (Development) #

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;

public class ElasticsearchConfig {

    public ElasticsearchClient createClient() {
        // Low-level REST client (manages HTTP connections)
        RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)
        ).build();

        // Transport layer with a JSON mapper
        ElasticsearchTransport transport = new RestClientTransport(
            restClient,
            new JacksonJsonpMapper()
        );

        // High-level typed client
        return new ElasticsearchClient(transport);
    }
}

Connection with Authentication and TLS (Production) #

import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.ssl.SSLContextBuilder;

import javax.net.ssl.SSLContext;
import java.io.File;
import java.nio.file.Files;

public class ElasticsearchProductionConfig {

    public ElasticsearchClient createSecureClient() throws Exception {
        // Load the CA certificate for TLS
        File certFile = new File("/path/to/http_ca.crt");
        byte[] certBytes = Files.readAllBytes(certFile.toPath());

        SSLContext sslContext = SSLContextBuilder.create()
            .loadTrustMaterial(null, (chains, authType) -> true) // trust all certs
            .build();

        // Set up credentials
        BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(
            AuthScope.ANY,
            new UsernamePasswordCredentials("elastic", "your-password")
        );

        RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200, "https")
        )
        .setHttpClientConfigCallback(httpClientBuilder ->
            httpClientBuilder
                .setSSLContext(sslContext)
                .setDefaultCredentialsProvider(credentialsProvider)
        )
        .build();

        ElasticsearchTransport transport = new RestClientTransport(
            restClient,
            new JacksonJsonpMapper()
        );

        return new ElasticsearchClient(transport);
    }
}

Using It as a Spring Bean #

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ElasticsearchBeanConfig {

    @Bean
    public ElasticsearchClient elasticsearchClient() {
        RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)
        ).build();

        ElasticsearchTransport transport = new RestClientTransport(
            restClient,
            new JacksonJsonpMapper()
        );

        return new ElasticsearchClient(transport);
    }
}

Mapping — Defining the Index Structure #

Mapping in Elasticsearch is equivalent to a schema in SQL. Defining the mapping before indexing data is very important — a field’s data type determines how the data is indexed and can be queried.

Important Data Types #

TypeUse CaseExample Value
textFull-text search, analyzed (tokenized)"Best Gaming Laptop"
keywordExact match, sorting, aggregation"gaming", "active"
integer, longWhole numbers15000000
double, floatDecimal numbers4.5
booleanTrue/falsetrue
dateDate and time"2024-01-15T08:00:00Z"
nestedArray of objects with preserved relationshipsProduct review list
geo_pointLatitude/longitude coordinates{ "lat": -6.2, "lon": 106.8 }

Creating an Index with Mapping #

import co.elastic.clients.elasticsearch.indices.CreateIndexResponse;
import co.elastic.clients.elasticsearch.indices.PutMappingResponse;

import java.io.IOException;

public class IndexManager {

    private final ElasticsearchClient client;

    public IndexManager(ElasticsearchClient client) {
        this.client = client;
    }

    public void createProductsIndex() throws IOException {
        // ANTI-PATTERN: letting ES auto-detect data types
        // Auto-detection is often wrong — e.g. price might be detected as long
        // when you need keyword for certain aggregations

        // CORRECT: define the mapping explicitly
        CreateIndexResponse response = client.indices().create(req -> req
            .index("products")
            .settings(s -> s
                .numberOfShards("3")    // split into 3 primary shards
                .numberOfReplicas("1")  // 1 replica per primary shard
            )
            .mappings(m -> m
                .properties("name", p -> p
                    .text(t -> t
                        .analyzer("indonesian")          // Indonesian text analysis
                        .fields("keyword", f -> f        // sub-field for exact match
                            .keyword(k -> k.ignoreAbove(256))
                        )
                    )
                )
                .properties("description", p -> p
                    .text(t -> t.analyzer("indonesian"))
                )
                .properties("category", p -> p
                    .keyword(k -> k)  // keyword: for exact-match filter & aggregation
                )
                .properties("price", p -> p
                    .long_(l -> l)
                )
                .properties("rating", p -> p
                    .float_(f -> f)
                )
                .properties("active", p -> p
                    .boolean_(b -> b)
                )
                .properties("tags", p -> p
                    .keyword(k -> k)
                )
                .properties("created_at", p -> p
                    .date(d -> d.format("yyyy-MM-dd'T'HH:mm:ssZ||epoch_millis"))
                )
            )
        );

        System.out.println("Index created: " + response.acknowledged());
    }
}
The name field uses the text type with a keyword sub-field. This is a common pattern in Elasticsearch: name (text) for full-text search, name.keyword (keyword) for exact match, sorting, and aggregation — two different needs, one field.

Document Operations #

Indexing Documents — Storing Data #

“Indexing” in Elasticsearch means storing a document while building its inverted index — different terminology from a regular SQL INSERT.

import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.bulk.BulkOperation;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

public class ProductRepository {

    private final ElasticsearchClient client;
    private static final String INDEX = "products";

    public ProductRepository(ElasticsearchClient client) {
        this.client = client;
    }

    // Product model
    public record Product(
        String id,
        String name,
        String description,
        String category,
        long price,
        float rating,
        boolean active,
        List<String> tags,
        String createdAt
    ) {}

    // Index a single document with an explicit ID
    public String saveProduct(Product product) throws IOException {
        IndexResponse response = client.index(req -> req
            .index(INDEX)
            .id(product.id())
            .document(product)
        );

        System.out.println("Result: " + response.result());
        return response.id();
    }

    // Bulk indexing — far more efficient for many documents
    public void saveManyProducts(List<Product> productList) throws IOException {
        // ANTI-PATTERN: calling index() one by one in a loop
        // This makes a separate HTTP request per document — very slow
        // for (Product p : productList) { client.index(...) } // DON'T

        // CORRECT: use the bulk API for batch indexing
        List<BulkOperation> operations = new ArrayList<>();
        for (Product product : productList) {
            operations.add(BulkOperation.of(op -> op
                .index(idx -> idx
                    .index(INDEX)
                    .id(product.id())
                    .document(product)
                )
            ));
        }

        BulkResponse response = client.bulk(req -> req.operations(operations));

        if (response.errors()) {
            response.items().stream()
                .filter(item -> item.error() != null)
                .forEach(item -> System.err.println(
                    "Error on ID " + item.id() + ": " + item.error().reason()
                ));
        }

        System.out.println("Indexed " + response.items().size() + " documents");
    }
}

Get, Update, Delete #

import co.elastic.clients.elasticsearch.core.GetResponse;
import co.elastic.clients.elasticsearch.core.UpdateResponse;
import co.elastic.clients.elasticsearch.core.DeleteResponse;

import java.util.Map;

public class ProductCRUD {

    private final ElasticsearchClient client;
    private static final String INDEX = "products";

    public ProductCRUD(ElasticsearchClient client) {
        this.client = client;
    }

    // Get a document by ID
    public Product getProduct(String id) throws IOException {
        GetResponse<Product> response = client.get(req -> req
            .index(INDEX)
            .id(id),
            Product.class
        );

        if (!response.found()) {
            return null;
        }
        return response.source();
    }

    // Partial update — only the included fields change
    public void updatePrice(String id, long newPrice) throws IOException {
        Map<String, Object> updateFields = Map.of(
            "price", newPrice,
            "updatedAt", LocalDateTime.now().toString()
        );

        UpdateResponse<Product> response = client.update(req -> req
            .index(INDEX)
            .id(id)
            .doc(updateFields),
            Product.class
        );

        System.out.println("Update result: " + response.result());
    }

    // Delete a document
    public boolean deleteProduct(String id) throws IOException {
        DeleteResponse response = client.delete(req -> req
            .index(INDEX)
            .id(id)
        );

        return response.result() == co.elastic.clients.elasticsearch._types.Result.Deleted;
    }
}

Query DSL — Searching Documents #

Query DSL is Elasticsearch’s JSON query language. It’s the most important part — understanding the right query type for each need determines the relevance and performance of your search.

import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.elasticsearch._types.query_dsl.*;

import java.util.List;
import java.util.stream.Collectors;

public class ProductSearch {

    private final ElasticsearchClient client;
    private static final String INDEX = "products";

    public ProductSearch(ElasticsearchClient client) {
        this.client = client;
    }

    // Match query: full-text search on a single field
    // Elasticsearch will analyze the query (tokenize, lowercase, etc.)
    public List<Product> searchByName(String keyword) throws IOException {
        SearchResponse<Product> response = client.search(req -> req
            .index(INDEX)
            .query(q -> q
                .match(m -> m
                    .field("name")
                    .query(keyword)
                    .fuzziness("AUTO") // typo tolerance: "laotop" can match "laptop"
                )
            )
            .size(20),
            Product.class
        );

        return response.hits().hits().stream()
            .map(Hit::source)
            .collect(Collectors.toList());
    }

    // Multi-match: search several fields at once
    public List<Product> searchMultiField(String keyword) throws IOException {
        SearchResponse<Product> response = client.search(req -> req
            .index(INDEX)
            .query(q -> q
                .multiMatch(m -> m
                    .fields("name^3", "description^1", "tags^2") // boost: name 3x, tags 2x
                    .query(keyword)
                    .type(TextQueryType.BestFields) // take the score from the best field
                )
            ),
            Product.class
        );

        return response.hits().hits().stream()
            .map(Hit::source)
            .collect(Collectors.toList());
    }

    // Term query: exact match, not analyzed — for keyword fields
    public List<Product> searchByCategory(String category) throws IOException {
        SearchResponse<Product> response = client.search(req -> req
            .index(INDEX)
            .query(q -> q
                // ANTI-PATTERN: using match for keyword fields
                // match will analyze the query, but keyword fields aren't analyzed
                // the result may not match even when the value is the same

                // CORRECT: use term for keyword fields
                .term(t -> t
                    .field("category")
                    .value(category)
                )
            ),
            Product.class
        );

        return response.hits().hits().stream()
            .map(Hit::source)
            .collect(Collectors.toList());
    }
}

Bool Query — Combining Conditions #

The bool query is the main building block for complex queries. It combines multiple queries with AND/OR/NOT logic.

public class BoolQueryExample {

    private final ElasticsearchClient client;

    public BoolQueryExample(ElasticsearchClient client) {
        this.client = client;
    }

    // Bool query: combination of must, should, filter, must_not
    public SearchResponse<Product> advancedSearch(
            String keyword,
            String category,
            long minPrice,
            long maxPrice,
            List<String> tags) throws IOException {

        return client.search(req -> req
            .index("products")
            .query(q -> q
                .bool(b -> {
                    // MUST: must match, affects the relevance score
                    if (keyword != null && !keyword.isEmpty()) {
                        b.must(m -> m.multiMatch(mm -> mm
                            .fields("name^3", "description")
                            .query(keyword)
                        ));
                    }

                    // FILTER: must match, does NOT affect the score (faster, cached)
                    b.filter(f -> f.term(t -> t.field("active").value(true)));

                    if (category != null) {
                        b.filter(f -> f.term(t -> t.field("category").value(category)));
                    }

                    b.filter(f -> f.range(r -> r
                        .field("price")
                        .gte(co.elastic.clients.json.JsonData.of(minPrice))
                        .lte(co.elastic.clients.json.JsonData.of(maxPrice))
                    ));

                    // SHOULD: nice if it matches, boosts the score but isn't required
                    if (tags != null && !tags.isEmpty()) {
                        for (String tag : tags) {
                            b.should(s -> s.term(t -> t.field("tags").value(tag)));
                        }
                        b.minimumShouldMatch("1"); // at least 1 tag must match
                    }

                    // MUST_NOT: must not match
                    b.mustNot(mn -> mn.term(t -> t.field("category").value("discontinued")));

                    return b;
                })
            )
            .sort(s -> s.score(sc -> sc.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)))
            .from(0)
            .size(20),
            Product.class
        );
    }
}

Bool Query Processing Flow #

sequenceDiagram
    participant App as Java App
    participant ES as Elasticsearch
    participant Cache as Filter Cache
    participant Scorer as Relevance Scorer

    App->>ES: Bool Query (must + filter + should)
    ES->>Cache: Check filter cache (active=true, category, price)
    Cache-->>ES: Filter result bitset (passing documents)
    ES->>Scorer: Calculate relevance scores (must + should)
    Scorer-->>ES: Score per document
    ES->>ES: Sort by score, apply pagination
    ES-->>App: Hits with score and highlight

Highlight — Highlighting Matching Words #

public List<Map<String, Object>> searchWithHighlight(String keyword) throws IOException {
    SearchResponse<Product> response = client.search(req -> req
        .index("products")
        .query(q -> q.match(m -> m.field("name").query(keyword)))
        .highlight(h -> h
            .fields("name", hf -> hf
                .numberOfFragments(0)           // return the whole field, not a fragment
                .preTags("<em class='highlight'>")
                .postTags("</em>")
            )
            .fields("description", hf -> hf
                .numberOfFragments(3)            // take the 3 most relevant fragments
                .fragmentSize(150)               // 150 characters per fragment
                .preTags("<mark>")
                .postTags("</mark>")
            )
        ),
        Product.class
    );

    return response.hits().hits().stream()
        .map(hit -> Map.of(
            "product", hit.source(),
            "highlight", hit.highlight()
        ))
        .collect(Collectors.toList());
}

Aggregations #

Aggregations let you calculate statistics, build charts, or create faceted navigation (e-commerce sidebar filters) directly from Elasticsearch.

Aggregation Types #

flowchart TD
    A[Aggregation] --> B[Bucket Aggregation\nGroup documents]
    A --> C[Metric Aggregation\nCalculate statistics]
    A --> D[Pipeline Aggregation\nAggregating other aggregations]

    B --> B1["terms\nGroup by field value"]
    B --> B2["range\nGroup by value range"]
    B --> B3["date_histogram\nGroup by time period"]

    C --> C1["avg, min, max, sum"]
    C --> C2["stats\n(all at once)"]
    C --> C3["cardinality\n(count distinct)"]

    D --> D1["moving_avg\nMoving average"]
    D --> D2["bucket_sort\nSort aggregation results"]

Faceted Search — E-Commerce Sidebar Filters #

import co.elastic.clients.elasticsearch._types.aggregations.*;

public class FacetedSearch {

    private final ElasticsearchClient client;

    public FacetedSearch(ElasticsearchClient client) {
        this.client = client;
    }

    public SearchResponse<Product> searchWithFacets(
            String keyword, String categoryFilter) throws IOException {

        return client.search(req -> req
            .index("products")
            .query(q -> {
                if (keyword != null && !keyword.isEmpty()) {
                    return q.match(m -> m.field("name").query(keyword));
                }
                return q.matchAll(m -> m);
            })
            // Filter by user selection (post_filter: doesn't affect aggregations)
            .postFilter(pf -> categoryFilter != null
                ? pf.term(t -> t.field("category").value(categoryFilter))
                : pf.matchAll(m -> m)
            )
            .aggregations("category_facet", a -> a
                // Terms aggregation: count products per category
                .terms(t -> t
                    .field("category")
                    .size(20)
                )
            )
            .aggregations("price_range", a -> a
                // Range aggregation: group by price range
                .range(r -> r
                    .field("price")
                    .ranges(
                        rng -> rng.to("500000").key("Below 500k"),
                        rng -> rng.from("500000").to("1000000").key("500k - 1M"),
                        rng -> rng.from("1000000").to("5000000").key("1M - 5M"),
                        rng -> rng.from("5000000").key("Above 5M")
                    )
                )
            )
            .aggregations("price_stats", a -> a
                // Stats aggregation: min, max, avg, sum all at once
                .stats(s -> s.field("price"))
            )
            .aggregations("products_per_month", a -> a
                // Date histogram: time trends
                .dateHistogram(dh -> dh
                    .field("created_at")
                    .calendarInterval(CalendarInterval.Month)
                    .format("yyyy-MM")
                )
            )
            .size(20),
            Product.class
        );
    }

    // Parsing aggregation results
    public void parseFacetResult(SearchResponse<Product> response) {
        // Get the terms aggregation buckets
        StringTermsAggregate categoryFacet = response.aggregations()
            .get("category_facet")
            .sterms();

        System.out.println("Available categories:");
        categoryFacet.buckets().array().forEach(bucket ->
            System.out.println("  " + bucket.key() + ": " + bucket.docCount() + " products")
        );

        // Get the stats aggregation
        StatsAggregate priceStats = response.aggregations()
            .get("price_stats")
            .stats();

        System.out.println("Minimum price: " + priceStats.min());
        System.out.println("Maximum price: " + priceStats.max());
        System.out.println("Average price: " + priceStats.avg());
    }
}

Pagination and Sorting #

Elasticsearch has two different pagination approaches for different needs.

From/Size — Regular Pagination #

public SearchResponse<Product> standardPagination(String keyword, int page, int perPage)
        throws IOException {

    // ANTI-PATTERN: using regular pagination for very deep pages
    // from=10000 means ES must collect 10,000 documents from all shards
    // then discard 9,990 — wasting memory and CPU

    // CORRECT: use from/size only for early pages (max ~10,000 total)
    int from = (page - 1) * perPage;

    return client.search(req -> req
        .index("products")
        .query(q -> q.match(m -> m.field("name").query(keyword)))
        .from(from)
        .size(perPage)
        .sort(s -> s.score(sc -> sc.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)))
        .sort(s -> s.field(f -> f  // secondary sort: stabilize the order
            .field("_id")
            .order(co.elastic.clients.elasticsearch._types.SortOrder.Asc)
        )),
        Product.class
    );
}

Search After — Efficient Deep Pagination #

import co.elastic.clients.elasticsearch._types.FieldValue;
import java.util.List;

public SearchResponse<Product> deepPagination(List<FieldValue> searchAfter) throws IOException {
    var requestBuilder = client.search(req -> {
        var builder = req
            .index("products")
            .query(q -> q.matchAll(m -> m))
            .sort(s -> s.field(f -> f
                .field("created_at")
                .order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)
            ))
            .sort(s -> s.field(f -> f.field("_id")))
            .size(20);

        // Continue from the last position using sort values
        if (searchAfter != null && !searchAfter.isEmpty()) {
            builder.searchAfter(searchAfter);
        }

        return builder;
    }, Product.class);

    // For the next request, take the sort values from the last hit:
    // List<FieldValue> nextCursor = response.hits().hits()
    //     .get(response.hits().hits().size() - 1)
    //     .sort();

    return requestBuilder;
}

Spring Data Elasticsearch Integration #

Spring Data Elasticsearch simplifies integration with a familiar pattern — similar to Spring Data MongoDB or Spring Data JPA.

Configuration #

# application.yml
spring:
  elasticsearch:
    uris: http://localhost:9200
    username: elastic
    password: your-password
    connection-timeout: 5s
    socket-timeout: 30s

Document Model #

import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.annotations.Setting;

import java.time.LocalDateTime;
import java.util.List;

@Document(indexName = "products")
@Setting(settingPath = "elasticsearch/products-settings.json") // custom analyzer
public class ProductDocument {

    @Id
    private String id;

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

    @Field(type = FieldType.Text, analyzer = "indonesian")
    private String description;

    @Field(type = FieldType.Keyword)
    private String category;

    @Field(type = FieldType.Long)
    private long price;

    @Field(type = FieldType.Float)
    private float rating;

    @Field(type = FieldType.Boolean)
    private boolean active;

    @Field(type = FieldType.Keyword)
    private List<String> tags;

    @Field(type = FieldType.Date, format = {}, pattern = "yyyy-MM-dd'T'HH:mm:ss")
    private LocalDateTime createdAt;

    // getters, setters, constructor...
}

Repository #

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

import java.util.List;

public interface ProductSearchRepository
        extends ElasticsearchRepository<ProductDocument, String> {

    // Automatic queries from method names
    List<ProductDocument> findByCategory(String category);

    Page<ProductDocument> findByActiveTrue(Pageable pageable);

    // Custom Elasticsearch query with @Query
    @Query("""
        {
          "bool": {
            "must": {
              "multi_match": {
                "query": "?0",
                "fields": ["name^3", "description"],
                "fuzziness": "AUTO"
              }
            },
            "filter": [
              { "term": { "active": true } },
              { "range": { "price": { "gte": ?1, "lte": ?2 } } }
            ]
          }
        }
        """)
    Page<ProductDocument> searchByKeywordAndPrice(
        String keyword, long minPrice, long maxPrice, Pageable pageable);
}

ElasticsearchOperations for Complex Queries #

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.Criteria;
import org.springframework.data.elasticsearch.core.query.CriteriaQuery;
import org.springframework.data.elasticsearch.core.query.NativeQuery;
import org.springframework.stereotype.Service;

import co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders;

@Service
public class ProductSearchService {

    @Autowired
    private ElasticsearchOperations operations;

    // Query with the Criteria API (type-safe, no need to write JSON)
    public SearchHits<ProductDocument> searchWithCriteria(
            String keyword, String category) {

        Criteria criteria = new Criteria("name").matches(keyword)
            .and(new Criteria("category").is(category))
            .and(new Criteria("active").is(true));

        CriteriaQuery query = new CriteriaQuery(criteria);
        return operations.search(query, ProductDocument.class);
    }

    // Native query for full control (use Query DSL directly)
    public SearchHits<ProductDocument> nativeSearch(String keyword) {
        NativeQuery query = NativeQuery.builder()
            .withQuery(q -> q
                .bool(b -> b
                    .must(m -> m.multiMatch(mm -> mm
                        .fields("name^3", "description")
                        .query(keyword)
                        .fuzziness("AUTO")
                    ))
                    .filter(f -> f.term(t -> t.field("active").value(true)))
                )
            )
            .withHighlightQuery(h -> h
                .withHighlightFields(Map.of("name", new HighlightField()))
            )
            .withPageable(PageRequest.of(0, 20))
            .build();

        return operations.search(query, ProductDocument.class);
    }
}

When to Use Elasticsearch #

Elasticsearch is a powerful tool, but not the solution to every problem. Adding Elasticsearch to your stack means adding operational complexity.

Use Elasticsearch when:
  ✓ You need relevant full-text search with scoring
  ✓ You need typo tolerance (fuzzy search) in searches
  ✓ You need faceted navigation (e-commerce sidebar filters)
  ✓ You need real-time analytics and aggregations on large data
  ✓ Log aggregation and monitoring (ELK Stack)
  ✓ Multi-language search with special analyzers

Consider alternatives when:
  ✗ You only need simple LIKE queries — PostgreSQL is enough
  ✗ Fewer than 100,000 documents — the overhead isn't worth it
  ✗ There's no team that can maintain an ES cluster
  ✗ You need strong consistency — ES is eventually consistent
  ✗ Limited budget — ES needs a lot of RAM (min 4GB per node)
Elasticsearch is eventually consistent — after indexing, a document may not be immediately searchable because of the refresh process (default every 1 second). Don’t use ES as the only storage for important data. The common pattern: store primary data in PostgreSQL/MongoDB, then sync to Elasticsearch for search needs.

Summary #

  • The inverted index is the foundation of Elasticsearch’s speed — it maps words to documents, not the other way around, so full-text search doesn’t need to scan all data.
  • Use the Java API Client (elasticsearch-java), not the deprecated HLRC. The new client is type-safe and uses idiomatic lambda builders.
  • Distinguish text and keywordtext for full-text search (analyzed, tokenized), keyword for exact match, sorting, and aggregation. Fields that need both can use multi-fields.
  • The bool query is the main building block — combine must (must match, scored), filter (must match, not scored, cached), should (score bonus), and must_not.
  • Use filter instead of must for non-textual conditions (price, status, dates) — filters are faster because their results are cached and they don’t calculate relevance scores.
  • The bulk API is required for mass indexing — don’t call index() one by one in a loop. The bulk API drastically reduces HTTP request overhead.
  • Aggregations enable faceted navigation, real-time statistics, and time trends directly from the search query — without separate queries.
  • ES isn’t a replacement for your primary database — use it as a search layer on top of the primary database (PostgreSQL, MongoDB). ES data can be rebuilt from the primary database if needed.
  • post_filter for faceted search — use post_filter (not filter inside the query) so the user’s filter selections don’t affect the facet aggregation counts.

← Previous: MongoDB   Next: Kafka →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact