In the Spring ecosystem, Spring Data JPA is the de facto ORM library to interact with RDBMS databases. The good news is Spring Data is not limited to JPA only. It supports many persistent data sources with the familiar repository-like workflow. One of these supported platforms is Elasticsearch. In this article, we explain getting started with Spring Data Elasticsearch. For this purpose, we will create a simple Rest endpoint to perform CRUD operations on Book
Importing dependencies
The first thing we need to do is to import the Elasticsearch library to the project like the below:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
It is good to note that Spring Data Elasticsearch does not support Elasticsearch version 8.8.0 at the time of writing this article.
Configuring Elasticsearch connection
Now that we have the Spring Data Elasticsearch in the project, it’s time to configure the Elasticsearch connection. Doing so is very straightforward. All we have to do is to add some properties to application.properties
like what we do with Spring Data JPA as follows:
spring.data.elasticsearch.repositories.enabled=true
# comma-separated list of the Elasticsearch instances to use.
spring.elasticsearch.uris=${ES_URI:localhost}:9200
Book model
After configuring the project, we have to create the model which is represented as a document in Elasticsearch. This model is quite similar to the Hibernate Entity. However, it has a limited number of annotations. Additionally, it does not have Jakarta
validation. We will explain how to apply constraints later on. Our Book
model has only
id
: auto-generated by Elasticsearchtitle
: book titlepublicationYear
: the year that the book was published, should be greater than 0 and not be in the futureauthorName
: the author of the bookisbn
: the book identification, should be unique
For the simplicity purpose, we don’t need to create author
document. We just want to start very simple.
The final model looks like this:
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
@Document(indexName = "books")
public class Book {
@Id
private String id;
private String title;
private int publicationYear;
private String authorName;
private String isbn;
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public int getPublicationYear() {
return publicationYear;
}
public void setPublicationYear(int publicationYear) {
this.publicationYear = publicationYear;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
}
Book repository
Now that we have our model ready, it’s time to create our book
repository. Creating the repository is no different than JPA one. Just instead of extending from JpaRepository
, we should extend from ElasticsearchRepository
. The syntax for creating custom queries is exactly the same as JPA.
The final code of the repository is as follows:
import com.madadipouya.elasticsearch.springdata.example.model.Book;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface BookRepository extends ElasticsearchRepository<Book, String> {
List<Book> findByAuthorName(String authorName);
Optional<Book> findByIsbn(String isbn);
}
Book service
The book service
resides on top of the repository and handles all business logic. For this service, we don’t solely rely on the Repository
ElasticsearchTemplate
which is similar to RestTemplate
. It provides a query builder to construct complex queries.
Besides that, the service is pretty straightforward. It does some checking and rudimentary exception handling.
package com.madadipouya.elasticsearch.springdata.example.service.impl;
import static co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders.match;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.stereotype.Service;
import com.madadipouya.elasticsearch.springdata.example.model.Book;
import com.madadipouya.elasticsearch.springdata.example.repository.BookRepository;
import com.madadipouya.elasticsearch.springdata.example.service.BookService;
import com.madadipouya.elasticsearch.springdata.example.service.exception.BookNotFoundException;
import com.madadipouya.elasticsearch.springdata.example.service.exception.DuplicateIsbnException;
import co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders;
@Service
public class DefaultBookService implements BookService {
private final BookRepository bookRepository;
private final ElasticsearchTemplate elasticsearchTemplate;
public DefaultBookService(BookRepository bookRepository, ElasticsearchTemplate elasticsearchTemplate) {
this.bookRepository = bookRepository;
this.elasticsearchTemplate = elasticsearchTemplate;
}
@Override
public Optional<Book> getByIsbn(String isbn) {
return bookRepository.findByIsbn(isbn);
}
@Override
public List<Book> getAll() {
List<Book> books = new ArrayList<>();
bookRepository.findAll()
.forEach(books::add);
return books;
}
@Override
public List<Book> findByAuthor(String authorName) {
return bookRepository.findByAuthorName(authorName);
}
@Override
public List<Book> findByTitleAndAuthor(String title, String author) {
var criteria = QueryBuilders.bool(builder -> builder.must(
match(queryAuthor -> queryAuthor.field("authorName").query(author)),
match(queryTitle -> queryTitle.field("title").query(title))
));
return elasticsearchTemplate.search(NativeQuery.builder().withQuery(criteria).build(), Book.class)
.stream().map(SearchHit::getContent).toList();
}
@Override
public Book create(Book book) throws DuplicateIsbnException {
if (getByIsbn(book.getIsbn()).isEmpty()) {
return bookRepository.save(book);
}
throw new DuplicateIsbnException(String.format("The provided ISBN: %s already exists. Use update instead!", book.getIsbn()));
}
@Override
public void deleteById(String id) {
bookRepository.deleteById(id);
}
@Override
public Book update(String id, Book book) throws BookNotFoundException {
Book oldBook = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException("There is not book associated with the given id"));
oldBook.setIsbn(book.getIsbn());
oldBook.setAuthorName(book.getAuthorName());
oldBook.setPublicationYear(book.getPublicationYear());
oldBook.setTitle(book.getTitle());
return bookRepository.save(oldBook);
}
}
Book controller
Next, we create the book controller to support CRUD operation and basic searching. The operations that the controller supports are:
- List books
GET
- Create a book
POST
- Search for a book by ISBN
GET
- Fuzzy search for books by author name and book title
GET
- Update a book details
PUT
- Delete a book of given id
DELETE
As mentioned earlier the model does not support Jakarta
validation. We have to handle it somewhere else. One way is to handle all the validations at the service level but soon the code will be difficult to maintain. A better approach is to apply validations at the DTO level. For that purpose, we will create a DTO with all necessary validation annotations such as @NotBlank
and @Positive
.
Additionally, in the requirement, we have a constraint that the publication year should not be a future year. How to handle that? Well, that’s pretty easy. All we have to do is to create a custom annotation and register it with a custom validator. In this example, this validation is done through @PublicationYear
annotation.
So the final controller code will look like this:
import com.madadipouya.elasticsearch.springdata.example.metadata.PublicationYear;
import com.madadipouya.elasticsearch.springdata.example.service.exception.BookNotFoundException;
import com.madadipouya.elasticsearch.springdata.example.model.Book;
import com.madadipouya.elasticsearch.springdata.example.service.BookService;
import com.madadipouya.elasticsearch.springdata.example.service.exception.DuplicateIsbnException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import java.util.List;
@RestController
@RequestMapping("/v1/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@ResponseStatus(HttpStatus.OK)
@GetMapping
public List<Book> getAllBooks() {
return bookService.getAll();
}
@ResponseStatus(HttpStatus.ACCEPTED)
@PostMapping
public Book createBook(@Valid @RequestBody BookDto book) throws DuplicateIsbnException {
return bookService.create(BookDto.transform(book));
}
@ResponseStatus(HttpStatus.OK)
@GetMapping(value = "/{isbn}")
public Book getBookByIsbn(@PathVariable String isbn) throws BookNotFoundException {
return bookService.getByIsbn(isbn).orElseThrow(() -> new BookNotFoundException("The given isbn is invalid"));
}
@ResponseStatus(HttpStatus.OK)
@GetMapping(value = "/query")
public List<Book> getBooksByAuthorAndTitle(@RequestParam(value = "title") String title, @RequestParam(value = "author") String author) {
return bookService.findByTitleAndAuthor(title, author);
}
@ResponseStatus(HttpStatus.OK)
@PutMapping(value = "/{id}")
public Book updateBook(@PathVariable String id, @RequestBody BookDto book) throws BookNotFoundException {
return bookService.update(id, BookDto.transform(book));
}
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping(value = "/{id}")
public void deleteBook(@PathVariable String id) {
bookService.deleteById(id);
}
public static class BookDto {
@NotBlank
private String title;
@Positive
@PublicationYear
private Integer publicationYear;
@NotBlank
private String authorName;
@NotBlank
private String isbn;
static Book transform(BookDto bookDto) {
Book book = new Book();
book.setTitle(bookDto.title);
book.setPublicationYear(bookDto.publicationYear);
book.setAuthorName(bookDto.authorName);
book.setIsbn(bookDto.isbn);
return book;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public Integer getPublicationYear() {
return publicationYear;
}
public void setPublicationYear(Integer publicationYear) {
this.publicationYear = publicationYear;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
}
}
Now that everything is ready, we can start the application. Before that, we need to ensure Elasticsearch is running. If you don’t want to install it on your machine, you can use the below docker-compose.yml
file:
elasticsearch:
image: elasticsearch:8.7.0
container_name: elasticsearch
ports:
- "9200:9200"
environment:
- discovery.type=single-node
- cluster.name=elasticsearch
# Since ES 8, SSL is on by default, disabling on local
- xpack.security.enabled=false
To run the above file, you need Docker Compose installed. Then can run it like the below:
$ docker-compose -f docker-compose.yml up -d
Conclusion
In this article, we covered getting started with Spring Data Elasticsearch. We used a hypothetical book example to create a fully functional application that uses Spring Data with Elasticsearch underneath as the data store. Additionally, we demonstrated how to use the ElasticsearchTemplate
to create custom complex queries such as fuzzy search. The full working example is available at GitHub at this link: https://github.com/kasramp/Spring-Data-ElasticSearch-Example
Finally, to know how to write integration tests using the Testcontainers library check this tutorial.
Inline/featured images credits
- Featured image by Davie Bicker from Pixabay