Getting started with Spring Data Elasticsearch

Getting started with Spring Data Elasticsearch

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 the Book resource using Spring Data Elasticsearch.

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 few simple properties:

  • id: auto-generated by Elasticsearch
  • title: book title
  • publicationYear: the year that the book was published, should be greater than 0 and not be in the future
  • authorName: the author of the book
  • isbn: 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 even though we could. So for the fuzzy query, we use 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