A LIKE '%pepperoni%' query can tell you whether a row matched. It cannot tell you how
well, it cannot cope with a typo, it cannot know that "pizzas" and "pizza" are the same word, and it
scans the whole table to find out. Elasticsearch does all of that.
It also means running a second database and keeping it in step with your first one, which is the part worth thinking about before you start.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>docker run -p 9200:9200 -e discovery.type=single-node \
-e xpack.security.enabled=false \
docker.elastic.co/elasticsearch/elasticsearch:9.1.0The pizza API keeps this switched off by default, because a demo that will not start without an Elasticsearch node is a bad demo:
# OFF by default. Without this, Spring Data would scan and register ProductSearchRepository,
# which then requires an ElasticsearchOperations bean and a reachable node at startup - so a
# plain `./mvnw spring-boot:run` would fail on any machine without Elasticsearch running.
spring.data.elasticsearch.repositories.enabled=false
spring.elasticsearch.uris=http://localhost:9200# application-search.properties — activated with --spring.profiles.active=local,search
spring.data.elasticsearch.repositories.enabled=true
spring.elasticsearch.uris=http://localhost:9200
spring.elasticsearch.connection-timeout=2s
spring.elasticsearch.socket-timeout=10sThe document is not the entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "products")
public class ProductDocument {
/** The product's public UUID as a string — the API never exposes the numeric key. */
@Id
private String id;
@Field(type = FieldType.Text)
private String name;
@Field(type = FieldType.Text)
private String description;
/** Filtered on, never searched as prose. */
@Field(type = FieldType.Keyword)
private String type;
@Field(type = FieldType.Boolean)
private boolean active;
@Field(type = FieldType.Double)
private BigDecimal fromPrice;
}A separate class from the JPA entity, on purpose. The two model different things:
the entity is normalised for correctness and transactions, the document is denormalised for search.
Annotating one class with both @Entity and @Document is possible and is a
trap — the field types you want in a search index have nothing to do with the column types you want in
MySQL, and you end up compromising both.
⚠️ Text versus Keyword
This is the single most common Elasticsearch mistake:
Textis analysed — broken into lowercase tokens, so "Pepperoni Feast" matches a search for "pepperoni". This is what makes full-text search work.Keywordis stored whole and matched exactly. Right for filtering, sorting and aggregating, and useless for free-text search.
Map name as Keyword and you get a search box that only matches exact
titles. Map type as Text and "filter by DRINK" starts returning pizzas,
because the analyser has tokenised your enum.
The repository
public interface ProductSearchRepository
extends ElasticsearchRepository<ProductDocument, String> {
/** Derived query: matches the analysed name field, filtered to active products. */
List<ProductDocument> findByNameAndActiveTrue(String name);
@Query("""
{
"bool": {
"must": [
{ "multi_match": {
"query": "?0",
"fields": ["name^3", "description"],
"fuzziness": "AUTO" } }
],
"filter": [ { "term": { "active": true } } ]
}
}
""")
List<ProductDocument> search(String term);
}The derived-query style is the same one Spring Data JPA uses — the method name is parsed into a query. That is the point: the programming model transfers, only the store changes.
When a method name stops being able to express the query, drop to the real thing. That
multi_match is doing three things a SQL query cannot:
"name^3"boosts the title, so a match in the name outranks one buried in a description.fuzziness: "AUTO"tolerates typos — "peperoni" finds "pepperoni".- Results come back ranked by relevance.
LIKEcan tell you whether a row matched, never how well.
Note also the must/filter split: must clauses contribute to
the score, filter clauses only include or exclude. Putting
active = true in filter is both faster and more correct — being active
should not make a product more relevant.
The hard part is staying in step
Elasticsearch is a second copy of data that MySQL owns. Every write has to reach the index too, and there is no transaction spanning both:
/**
* <p>The three ways to keep them in step, worst to best:
*
* <ol>
* <li><b>Index inside the write transaction.</b> Simple, and wrong: the write now fails when
* Elasticsearch is down, and a rollback cannot un-index.
* <li><b>Index after commit</b> (an {@code AFTER_COMMIT} listener, as this app does for email).
* Good, but a crash between commit and index loses the update silently.
* <li><b>Rebuild periodically as well</b> — a scheduled full reindex that repairs whatever the
* incremental path missed. Belt and braces, and the only version that self-heals.
* </ol>
*/The repair path:
@Override
@Transactional(readOnly = true)
public long reindexAll() {
List<Product> products = productDAO.getAll();
List<ProductDocument> documents = products.stream()
.map(product -> ProductDocument.from(product, cheapestSize(product)))
.toList();
// saveAll is a bulk request, not N round trips. Indexing one document per HTTP call is the
// classic reason a reindex takes twenty minutes instead of twenty seconds.
searchRepository.saveAll(documents);
log.info("Reindexed {} products", documents.size());
return documents.size();
}Accept that the index is eventually consistent and design around it, rather than pretending otherwise. Which leads to the most important rule here:
The index ranks; the database is the truth
List<ProductDocument> hits = searchRepository.search(term.trim());
// The index holds a denormalised copy, which is right for matching and ranking but is NOT
// the source of truth for price or availability. Read the ids from Elasticsearch, then load
// the real rows from MySQL — so a stale document can affect the ORDER of results but never
// the prices a customer is shown.
Map<String, Integer> rank = new HashMap<>();
for (int i = 0; i < hits.size(); i++) {
rank.put(hits.get(i).getId(), i);
}
return hits.stream()
.map(hit -> productDAO.findByPublicIdWithSizes(UUID.fromString(hit.getId())))
.flatMap(Optional::stream)
.filter(Product::isActive)
.sorted(Comparator.comparingInt(
p -> rank.getOrDefault(String.valueOf(p.getPublicId()), Integer.MAX_VALUE)))
.map(mapper::mapProductToProductDTO)
.toList();Elasticsearch decides which products and in what order. MySQL supplies what they cost and whether they are still available. A stale document then costs you a slightly odd ordering rather than a wrong price on a checkout page.
Degrade, do not disappear
private final ObjectProvider<ProductSearchService> searchServiceProvider;
@GetMapping("/products")
public ResponseEntity<List<ProductDTO>> search(@RequestParam("q") String term) {
ProductSearchService searchService = searchServiceProvider.getIfAvailable();
if (searchService != null) {
return ResponseEntity.ok(searchService.search(term));
}
return ResponseEntity.ok(databaseSearch(term));
}With the profile off there is no search service, so the endpoint falls back to a database scan. The API always answers — better results with Elasticsearch, adequate ones without.
That fallback is also the honest comparison. The database version is a case-insensitive substring scan: no ranking, no fuzziness, no stemming, and slower as the catalogue grows. Everything Elasticsearch is for is visible in the difference between the two branches.
Do you actually need it?
Be honest about the cost: a cluster to run, monitor, secure and upgrade; a second copy of your data that can silently drift; and a whole query language to learn.
You probably do not need it if your dataset is small, exact matching is enough, or
your database's own full-text search would do. MySQL and PostgreSQL both have one, and PostgreSQL's
is genuinely good — tsvector with a GIN index handles stemming and ranking without
another server.
You probably do if you have millions of documents, users expect typo tolerance and relevance ranking, you need faceted search or aggregations over text, or you are searching across several sources at once.
For a fourteen-product pizza menu, the database fallback above is the correct engineering answer. It is in this track because the pattern matters at scale, and because the two implementations sitting side by side make the trade-off concrete.
What to take from this
- Separate document class from the JPA entity.
Textfor searching,Keywordfor filtering. Getting this backwards is the classic mistake.- Index after commit, and reindex periodically to self-heal.
- Search for ids and ranking; load the truth from your database.
- Gate it off by default so the app runs without a node.
Next: how Spring Security authenticates — the filter chain and the four pieces everything else is built on.