diff --git a/README.md b/README.md
index 002290f..5d41519 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin
|---|---|---|
| [`time-series/`](./time-series) | Time-Series Data in Java: QuestDB vs InfluxDB vs TimescaleDB | QuestDB 10.0.1, InfluxDB 3 Core 3.12.0, TimescaleDB 2.25.1 (PG17) |
| [`mongodb/`](./mongodb) | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 9.0.2 single-node replica set, Spring Data MongoDB |
-| `elasticsearch/` | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9, Spring Data Elasticsearch |
+| [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 |
| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis |
Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself.
diff --git a/elasticsearch/docs/output/27-category-facets.txt b/elasticsearch/docs/output/27-category-facets.txt
new file mode 100644
index 0000000..30982ee
--- /dev/null
+++ b/elasticsearch/docs/output/27-category-facets.txt
@@ -0,0 +1,2 @@
+query="desk" facet counts by category:
+ furniture=2
diff --git a/elasticsearch/docs/output/27-fuzzy-search.txt b/elasticsearch/docs/output/27-fuzzy-search.txt
new file mode 100644
index 0000000..3326529
--- /dev/null
+++ b/elasticsearch/docs/output/27-fuzzy-search.txt
@@ -0,0 +1,4 @@
+query="wireles" (misspelled) max_price=1000.00
+ Wireless Mechanical Keyboard ($89.99, electronics)
+ Wireless Earbuds Pro ($129.0, electronics)
+ Ergonomic Office Chair ($259.0, furniture)
diff --git a/elasticsearch/docs/output/27-price-filter.txt b/elasticsearch/docs/output/27-price-filter.txt
new file mode 100644
index 0000000..8b28ef0
--- /dev/null
+++ b/elasticsearch/docs/output/27-price-filter.txt
@@ -0,0 +1,2 @@
+query="wireless" max_price=100.00
+ Wireless Mechanical Keyboard ($89.99)
diff --git a/elasticsearch/docs/output/27-relevance-boost.txt b/elasticsearch/docs/output/27-relevance-boost.txt
new file mode 100644
index 0000000..74f230b
--- /dev/null
+++ b/elasticsearch/docs/output/27-relevance-boost.txt
@@ -0,0 +1,4 @@
+query="wireless" max_price=1000.00 (ranked by relevance)
+ Wireless Mechanical Keyboard
+ Wireless Earbuds Pro
+ Ergonomic Office Chair
diff --git a/elasticsearch/pom.xml b/elasticsearch/pom.xml
new file mode 100644
index 0000000..55feb49
--- /dev/null
+++ b/elasticsearch/pom.xml
@@ -0,0 +1,72 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ elasticsearch-demo
+ 1.0.0
+ elasticsearch-demo
+
+
+ 25
+ 2.0.5
+
+
+
+
+
+ org.testcontainers
+ testcontainers-bom
+ ${testcontainers.version}
+ pom
+ import
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-elasticsearch
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+ org.testcontainers
+ testcontainers-elasticsearch
+ test
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/ElasticsearchDemoApplication.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ElasticsearchDemoApplication.java
new file mode 100644
index 0000000..33d37ce
--- /dev/null
+++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ElasticsearchDemoApplication.java
@@ -0,0 +1,11 @@
+package com.ankurm.elasticsearch;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class ElasticsearchDemoApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(ElasticsearchDemoApplication.class, args);
+ }
+}
diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/Product.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/Product.java
new file mode 100644
index 0000000..57c37e7
--- /dev/null
+++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/Product.java
@@ -0,0 +1,28 @@
+package com.ankurm.elasticsearch;
+
+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 java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * name and description are mapped "text" so Elasticsearch analyzes and
+ * tokenizes them for full-text matching; category is mapped "keyword" so
+ * it sorts, filters and aggregates on the exact string, not analyzed
+ * tokens. Mapping a field the wrong way is the single most common
+ * Elasticsearch mistake, and it fails silently - a "keyword" field still
+ * accepts text, it just won't match partial words, and a "text" field
+ * still accepts a filter, it just won't filter exactly.
+ */
+@Document(indexName = "products")
+public record Product(
+ @Id String id,
+ @Field(type = FieldType.Text) String name,
+ @Field(type = FieldType.Text) String description,
+ @Field(type = FieldType.Keyword) String category,
+ @Field(type = FieldType.Double) BigDecimal price,
+ @Field(type = FieldType.Keyword) List tags) {
+}
diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductRepository.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductRepository.java
new file mode 100644
index 0000000..fddb121
--- /dev/null
+++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductRepository.java
@@ -0,0 +1,9 @@
+package com.ankurm.elasticsearch;
+
+import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
+
+import java.util.List;
+
+public interface ProductRepository extends ElasticsearchRepository {
+ List findByCategory(String category);
+}
diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductSearchService.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductSearchService.java
new file mode 100644
index 0000000..4d8e882
--- /dev/null
+++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductSearchService.java
@@ -0,0 +1,86 @@
+package com.ankurm.elasticsearch;
+
+import co.elastic.clients.elasticsearch._types.aggregations.Aggregation;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHit;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ElasticsearchRepository covers point lookups and exact-field derived
+ * queries; relevance-ranked full-text search with filters and facets
+ * needs the query DSL, built here with the elasticsearch-java client's
+ * own type-safe builders rather than hand-written JSON strings.
+ */
+@Service
+public class ProductSearchService {
+
+ private final ElasticsearchOperations operations;
+
+ public ProductSearchService(ElasticsearchOperations operations) {
+ this.operations = operations;
+ }
+
+ /**
+ * Multi-field search with per-field relevance boosting (a match in the
+ * name counts for more than the same match in the description) and
+ * fuzziness so a typo like "wireles" still finds "wireless". The price
+ * ceiling is a filter, not part of the relevance query - filters narrow
+ * the result set without affecting how matches are scored.
+ */
+ public List search(String term, BigDecimal maxPrice) {
+ Query textQuery = Query.of(q -> q.multiMatch(m -> m
+ .query(term)
+ .fields("name^2", "description")
+ .fuzziness("AUTO")));
+
+ Query priceFilter = Query.of(q -> q.range(r -> r
+ .number(n -> n.field("price").lte(maxPrice.doubleValue()))));
+
+ Query combined = Query.of(q -> q.bool(b -> b
+ .must(textQuery)
+ .filter(priceFilter)));
+
+ NativeQuery query = NativeQuery.builder().withQuery(combined).build();
+ SearchHits hits = operations.search(query, Product.class);
+ return hits.getSearchHits().stream().map(SearchHit::getContent).toList();
+ }
+
+ /**
+ * A terms aggregation over the (unanalyzed) category keyword field -
+ * the facet counts a storefront sidebar shows next to each category
+ * name, computed by Elasticsearch over the matching set, not pulled
+ * back and counted client-side.
+ */
+ public Map categoryFacets(String term) {
+ Query textQuery = Query.of(q -> q.multiMatch(m -> m
+ .query(term)
+ .fields("name^2", "description")
+ .fuzziness("AUTO")));
+
+ NativeQuery query = NativeQuery.builder()
+ .withQuery(textQuery)
+ .withAggregation("by_category", Aggregation.of(a -> a
+ .terms(t -> t.field("category"))))
+ .withMaxResults(0)
+ .build();
+
+ SearchHits hits = operations.search(query, Product.class);
+ Map facets = new java.util.LinkedHashMap<>();
+ var aggregations = hits.getAggregations();
+ if (aggregations != null) {
+ var elc = (org.springframework.data.elasticsearch.client.elc.ElasticsearchAggregations) aggregations;
+ var byCategory = elc.get("by_category").aggregation().getAggregate().sterms();
+ for (var bucket : byCategory.buckets().array()) {
+ facets.put(bucket.key().stringValue(), bucket.docCount());
+ }
+ }
+ return facets;
+ }
+}
diff --git a/elasticsearch/src/test/java/com/ankurm/elasticsearch/ProductSearchTest.java b/elasticsearch/src/test/java/com/ankurm/elasticsearch/ProductSearchTest.java
new file mode 100644
index 0000000..9b5bff3
--- /dev/null
+++ b/elasticsearch/src/test/java/com/ankurm/elasticsearch/ProductSearchTest.java
@@ -0,0 +1,119 @@
+package com.ankurm.elasticsearch;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Runs the search, filter and aggregation behaviour against a real
+ * Elasticsearch 9.4.8 node (matching the elasticsearch-java client
+ * version 9.4.5 that spring-boot-starter-data-elasticsearch 4.1.1
+ * actually resolves - verified with mvn dependency:tree, not assumed).
+ * Every number in the post's transcripts comes from these tests.
+ */
+@Testcontainers
+@SpringBootTest
+class ProductSearchTest {
+
+ @Container
+ @ServiceConnection
+ static ElasticsearchContainer elasticsearch =
+ new ElasticsearchContainer("elasticsearch:9.4.8")
+ .withEnv("xpack.security.enabled", "false");
+
+ @Autowired ProductRepository repository;
+ @Autowired ProductSearchService searchService;
+ @Autowired ElasticsearchOperations operations;
+
+ @BeforeEach
+ void seedCatalog() {
+ if (operations.indexOps(Product.class).exists()) {
+ operations.indexOps(Product.class).delete();
+ }
+ operations.indexOps(Product.class).createWithMapping();
+
+ repository.saveAll(List.of(
+ new Product(null, "Wireless Mechanical Keyboard", "A compact 65% keyboard with hot-swappable switches", "electronics", new BigDecimal("89.99"), List.of("keyboard", "wireless")),
+ new Product(null, "Wireless Earbuds Pro", "Noise-cancelling earbuds with a 30-hour battery case", "electronics", new BigDecimal("129.00"), List.of("audio", "wireless")),
+ new Product(null, "USB-C Charging Cable", "Braided cable rated for fast charging", "electronics", new BigDecimal("12.50"), List.of("cable")),
+ new Product(null, "Oak Standing Desk", "Electric height-adjustable desk with a solid oak top", "furniture", new BigDecimal("449.00"), List.of("desk", "office")),
+ new Product(null, "Ergonomic Office Chair", "Mesh-back chair with lumbar support and wireless lumbar adjustment motor", "furniture", new BigDecimal("259.00"), List.of("chair", "office")),
+ new Product(null, "Desk Lamp", "LED desk lamp with adjustable color temperature", "furniture", new BigDecimal("34.00"), List.of("lamp"))));
+
+ operations.indexOps(Product.class).refresh();
+ }
+
+ @Test
+ void fuzzySearchFindsAMisspelledTerm() {
+ List results = searchService.search("wireles", new BigDecimal("1000"));
+ Transcript t = new Transcript("27-fuzzy-search.txt");
+ t.line("query=\"wireles\" (misspelled) max_price=1000.00");
+ for (Product p : results) {
+ t.line(" %s ($%s, %s)", p.name(), p.price(), p.category());
+ }
+ t.write();
+
+ assertThat(results).extracting(Product::name)
+ .contains("Wireless Mechanical Keyboard", "Wireless Earbuds Pro");
+ }
+
+ @Test
+ void nameMatchOutranksDescriptionOnlyMatch() {
+ // "Wireless" appears in two product NAMES and once only in a
+ // description (the chair's lumbar motor) - the ^2 boost on name
+ // should put both name matches ahead of the description-only one.
+ List results = searchService.search("wireless", new BigDecimal("1000"));
+ Transcript t = new Transcript("27-relevance-boost.txt");
+ t.line("query=\"wireless\" max_price=1000.00 (ranked by relevance)");
+ for (Product p : results) {
+ t.line(" %s", p.name());
+ }
+ t.write();
+
+ List names = results.stream().map(Product::name).toList();
+ int keyboardRank = names.indexOf("Wireless Mechanical Keyboard");
+ int earbudsRank = names.indexOf("Wireless Earbuds Pro");
+ int chairRank = names.indexOf("Ergonomic Office Chair");
+ assertThat(keyboardRank).isGreaterThanOrEqualTo(0);
+ assertThat(earbudsRank).isGreaterThanOrEqualTo(0);
+ assertThat(chairRank).isGreaterThan(Math.max(keyboardRank, earbudsRank));
+ }
+
+ @Test
+ void priceFilterExcludesMatchesOverTheCeiling() {
+ List underBudget = searchService.search("wireless", new BigDecimal("100"));
+ Transcript t = new Transcript("27-price-filter.txt");
+ t.line("query=\"wireless\" max_price=100.00");
+ for (Product p : underBudget) {
+ t.line(" %s ($%s)", p.name(), p.price());
+ }
+ t.write();
+
+ assertThat(underBudget).extracting(Product::name)
+ .contains("Wireless Mechanical Keyboard")
+ .doesNotContain("Wireless Earbuds Pro");
+ }
+
+ @Test
+ void categoryFacetsCountMatchesPerCategory() {
+ Map facets = searchService.categoryFacets("desk");
+ Transcript t = new Transcript("27-category-facets.txt");
+ t.line("query=\"desk\" facet counts by category:");
+ facets.forEach((category, count) -> t.line(" %s=%d", category, count));
+ t.write();
+
+ assertThat(facets).containsEntry("furniture", 2L);
+ }
+}
diff --git a/elasticsearch/src/test/java/com/ankurm/elasticsearch/Transcript.java b/elasticsearch/src/test/java/com/ankurm/elasticsearch/Transcript.java
new file mode 100644
index 0000000..f4bdb86
--- /dev/null
+++ b/elasticsearch/src/test/java/com/ankurm/elasticsearch/Transcript.java
@@ -0,0 +1,40 @@
+package com.ankurm.elasticsearch;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes docs/output/NN-*.txt while a test asserts the same numbers, so
+ * every figure quoted in the blog post is backed by an assertion that
+ * fails the build when it stops being true.
+ */
+final class Transcript {
+ private final List lines = new ArrayList<>();
+ private final Path file;
+
+ Transcript(String name) {
+ this.file = Path.of("docs/output/" + name);
+ }
+
+ Transcript line(String s) {
+ lines.add(s);
+ System.out.println(s);
+ return this;
+ }
+
+ Transcript line(String fmt, Object... args) {
+ return line(fmt.formatted(args));
+ }
+
+ void write() {
+ try {
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, String.join("\n", lines) + "\n");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}