Mastering Hotel Restaurant Search with Elasticsearch and Spring Boot: A Comprehensive Guide (Part 1)


Elasticsearch, a distributed search and analytics engine built on Apache Lucene, is renowned for its speed and flexibility in handling complex search and filtering tasks. When paired with Spring Boot, a robust Java framework for building scalable backend applications, it becomes an ideal solution for creating a powerful search system for hotel restaurants. This comprehensive guide walks you through building a Spring Boot backend that leverages Elasticsearch to search and filter hotel restaurants by criteria such as cuisine, price range, rating, location, and more. We’ll include advanced features like autocomplete, pagination, and geospatial queries, along with detailed explanations, code examples, and best practices.
Why Elasticsearch and Spring Boot for Hotel Restaurant Search?
Elasticsearch excels at full-text search, structured queries, and aggregations, making it perfect for searching large datasets of hotel restaurants. Spring Boot complements this with its rapid development capabilities, auto-configuration, and seamless integration with Elasticsearch via Spring Data. Together, they offer:
Lightning-Fast Search: Elasticsearch’s inverted index delivers near real-time search results.
Complex Filtering: Support for filtering by multiple criteria (e.g., cuisine, price, location) with minimal latency.
Scalability: Both technologies are designed for distributed environments, ensuring performance as data grows.
Developer Productivity: Spring Boot’s abstractions reduce boilerplate, while Elasticsearch’s query DSL enables flexible queries.
RESTful APIs: Expose search functionality to front-end or mobile applications with ease.
This combination is ideal for hospitality platforms where users need to find restaurants based on specific preferences, such as proximity, budget, or cuisine type.
Prerequisites
Before diving in, ensure you have:
Java 17+: Spring Boot 3.x requires Java 17 or later.
Elasticsearch 8.x: Installed locally (via Docker or direct download from elastic.co) or hosted on Elastic Cloud.
Maven: For dependency management (Gradle is also supported).
IDE: IntelliJ IDEA, Eclipse, or VS Code with Java support.
Postman or curl: For testing REST APIs.
Basic Knowledge: Familiarity with Spring Boot, REST APIs, JSON, and Elasticsearch concepts like indices and mappings.
Docker (Optional): For running Elasticsearch in a container.
Step 1: Setting Up the Environment
Install and Run Elasticsearch
Local Installation:
Download Elasticsearch 8.x from elastic.co.
Extract and run it using:
./bin/elasticsearch
By default, Elasticsearch runs on http://localhost:9200. Verify it’s running with:
curl http://localhost:9200
You should see a JSON response with cluster details.
Docker (Alternative):
Run Elasticsearch in a Docker container:
docker run -d --name elasticsearch -p 9200:9200 -e "discovery.type=single-node" elasticsearch:8.15.0
Ensure the container is running (docker ps) and accessible.
Elastic Cloud (Optional):
- Sign up for Elastic Cloud, create a deployment, and note the endpoint URL and credentials.
Create a Spring Boot Project
Use Spring Initializr to generate a project with:
Dependencies: Spring Web, Spring Data Elasticsearch, Lombok (optional for reducing boilerplate).
Java Version: 17 or later.
Build Tool: Maven (or Gradle).
Download, unzip, and open the project in your IDE.
Configure Dependencies
In your pom.xml, ensure the following dependencies are included:
<dependencies>
<!-- Spring Web for REST APIs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data Elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- Lombok for reducing boilerplate -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Configure Elasticsearch Connection
In src/main/resources/application.properties, configure the Elasticsearch connection. For a local setup:
spring.elasticsearch.uris=http://localhost:9200
spring.elasticsearch.connection-timeout=10s
spring.elasticsearch.socket-timeout=10s
spring.elasticsearch.restclient.sniff=false
For Elastic Cloud, include credentials:
spring.elasticsearch.uris=https://<your-cluster>.elastic-cloud.com
spring.elasticsearch.username=elastic
spring.elasticsearch.password=<your-password>
Verify Connection
Create a simple test to ensure Spring Boot connects to Elasticsearch:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.elasticsearch.client.RestHighLevelClient;
@Component
public class ElasticsearchConnectionTest implements CommandLineRunner {
@Autowired
private RestHighLevelClient client;
@Override
public void run(String... args) throws Exception {
if (client.ping()) {
System.out.println("Successfully connected to Elasticsearch!");
} else {
System.out.println("Failed to connect to Elasticsearch.");
}
}
}
Run the application (mvn spring-boot:run) and check the console for the connection message.
Step 2: Defining the Restaurant Data Model
The restaurant data will be stored as documents in an Elasticsearch index called hotel_restaurants. Each document represents a restaurant with fields like name, cuisine, price range, rating, and location.
Create the Restaurant Entity
Define a Restaurant class with annotations for Elasticsearch mapping:
import lombok.Data;
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.GeoPointField;
import org.springframework.data.elasticsearch.core.geo.GeoPoint;
@Data
@Document(indexName = "hotel_restaurants")
public class Restaurant {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "standard")
private String name;
@Field(type = FieldType.Keyword)
private String cuisine;
@Field(type = FieldType.Keyword)
private String priceRange;
@Field(type = FieldType.Float)
private Float rating;
@GeoPointField
private GeoPoint location;
@Field(type = FieldType.Text)
private String description;
}
Key Annotations:
@Document(indexName = "hotel_restaurants"): Specifies the Elasticsearch index.
@Id: Marks the unique identifier for each document.
@Field(type = FieldType.Text, analyzer = "standard"): Enables full-text search on the name field with the standard analyzer for tokenization.
@Field(type = FieldType.Keyword): Ensures exact matching for fields like cuisine and priceRange.
@GeoPointField: Supports geospatial queries for the location field.
@Field(type = FieldType.Text): Allows full-text search on the description field for richer search capabilities.
Understanding Elasticsearch Mappings
Mappings define how fields are indexed and queried. For example:
Text fields are analyzed (tokenized) for full-text search, e.g., searching "Ocean Breeze" matches "Ocean" or "Breeze."
Keyword fields are not tokenized, ensuring exact matches, e.g., filtering cuisine="Italian".
GeoPoint fields enable geospatial queries like finding restaurants within a radius.
You can customize mappings further (e.g., using custom analyzers for case-insensitive search) if needed.
Step 3: Creating the Elasticsearch Repository
Spring Data Elasticsearch provides a repository abstraction to simplify CRUD operations and queries. Create a repository interface for the Restaurant entity:
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 RestaurantRepository extends ElasticsearchRepository<Restaurant, String> {
// Find by cuisine and price range
List<Restaurant> findByCuisineAndPriceRange(String cuisine, String priceRange);
// Find by minimum rating
List<Restaurant> findByRatingGreaterThanEqual(Float rating);
// Autocomplete search by name
@Query("{\"match\": {\"name\": {\"query\": \"?0\", \"fuzziness\": \"AUTO\"}}}")
List<Restaurant> findByNameContaining(String name);
// Geo-distance query
@Query("{\"bool\": {\"filter\": {\"geo_distance\": {\"distance\": \"?0\", \"location\": {\"lat\": ?1, \"lon\": ?2}}}}}")
Page<Restaurant> findByLocationWithin(String distance, Double lat, Double lon, Pageable pageable);
}
Key Methods:
findByCuisineAndPriceRange: Filters restaurants by exact cuisine and price range.
findByRatingGreaterThanEqual: Retrieves restaurants with a rating above or equal to the specified value.
findByNameContaining: Uses a custom query with fuzziness for autocomplete-like search.
findByLocationWithin: Performs a geospatial query to find restaurants within a specified distance, with pagination support.
Pagination: The Pageable parameter enables paginated results, crucial for handling large datasets efficiently.
Step 4: Indexing Sample Data
To test the search functionality, index sample restaurant data. Create a service to populate the hotel_restaurants index.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.geo.GeoPoint;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.List;
@Service
public class RestaurantService {
@Autowired
private RestaurantRepository repository;
@PostConstruct
public void initData() {
// Clear existing data (optional, for testing)
repository.deleteAll();
// Sample restaurants
Restaurant restaurant1 = new Restaurant();
restaurant1.setId("1");
restaurant1.setName("Ocean Breeze Cafe");
restaurant1.setCuisine("Italian");
restaurant1.setPriceRange("$$");
restaurant1.setRating(4.5f);
restaurant1.setLocation(new GeoPoint(40.7128, -74.0060)); // New York City
restaurant1.setDescription("Cozy Italian cafe with a view of the ocean, serving authentic pasta and pizza.");
Restaurant restaurant2 = new Restaurant();
restaurant2.setId("2");
restaurant2.setName("Spice Garden");
restaurant2.setCuisine("Indian");
restaurant2.setPriceRange("$$$");
restaurant2.setRating(4.0f);
restaurant2.setLocation(new GeoPoint(40.7282, -73.9876)); // Nearby NYC location
restaurant2.setDescription("Vibrant Indian restaurant offering spicy curries and tandoori specialties.");
Restaurant restaurant3 = new Restaurant();
restaurant3.setId("3");
restaurant3.setName("Sushi Haven");
restaurant3.setCuisine("Japanese");
restaurant3.setPriceRange("$$");
restaurant3.setRating(4.8f);
restaurant3.setLocation(new GeoPoint(40.7050, -74.0100));
restaurant3.setDescription("Modern Japanese restaurant specializing in fresh sushi and sashimi.");
repository.saveAll(List.of(restaurant1, restaurant2, restaurant3));
}
}
Notes:
The @PostConstruct annotation ensures data is indexed when the application starts.
repository.deleteAll() clears the index for clean testing (remove in production).
The description field enables richer full-text search capabilities.
Step 5: Building a Comprehensive Search REST API
Create a REST controller to expose endpoints for searching and filtering restaurants. This controller will handle various query types, including full-text search, filtering, autocomplete, and geospatial queries.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/restaurants")
public class RestaurantController {
@Autowired
private RestaurantRepository repository;
// General search with filters
@GetMapping("/search")
public List<Restaurant> searchRestaurants(
@RequestParam(required = false) String cuisine,
@RequestParam(required = false) String priceRange,
@RequestParam(required = false, defaultValue = "0.0") Float minRating
) {
if (cuisine != null && priceRange != null) {
return repository.findByCuisineAndPriceRange(cuisine, priceRange);
} else if (minRating > 0) {
return repository.findByRatingGreaterThanEqual(minRating);
}
return repository.findAll().getContent();
}
// Autocomplete search by name
@GetMapping("/autocomplete")
public List<Restaurant> autocomplete(
@RequestParam String query
) {
return repository.findByNameContaining(query);
}
// Nearby restaurants with pagination
@GetMapping("/nearby")
public Page<Restaurant> findNearbyRestaurants(
@RequestParam Double lat,
@RequestParam Double lon,
@RequestParam(defaultValue = "10km") String distance,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
Pageable pageable = PageRequest.of(page, size);
return repository.findByLocationWithin(distance, lat, lon, pageable);
}
}
Endpoints Explained:
/search: Filters by cuisine, price range, and/or minimum rating. Returns a list of matching restaurants.
/autocomplete: Provides autocomplete suggestions based on partial name matches (e.g., typing "Oce" suggests "Ocean Breeze Cafe").
/nearby: Finds restaurants within a specified distance from a given latitude and longitude, with pagination support.
Pagination: The Pageable parameter in the /nearby endpoint allows clients to request specific pages of results, improving performance for large datasets.
Step 6: Example Scenarios and Testing
Let’s walk through practical examples to demonstrate the API’s capabilities.
Scenario 1: Filter by Cuisine and Price Range
Goal: Find Italian restaurants with a price range of "$$".
API Call (using curl or Postman):
curl "http://localhost:8080/api/restaurants/search?cuisine=Italian&priceRange=$$"
Response:
[
{
"id": "1",
"name": "Ocean Breeze Cafe",
"cuisine": "Italian",
"priceRange": "$$",
"rating": 4.5,
"location": {
"lat": 40.7128,
"lon": -74.0060
},
"description": "Cozy Italian cafe with a view of the ocean, serving authentic pasta and pizza."
},
{
"id": "3",
"name": "Sushi Haven",
"cuisine": "Japanese",
"priceRange": "$$",
"rating": 4.8,
"location": {
"lat": 40.7050,
"lon": -74.0100
},
"description": "Modern Japanese restaurant specializing in fresh sushi and sashimi."
}
]
Scenario 2: Autocomplete Search
Goal: Find restaurants with names starting with "Sp".
API Call:
curl "http://localhost:8080/api/restaurants/autocomplete?query=Sp"
Response:
[
{
"id": "2",
"name": "Spice Garden",
"cuisine": "Indian",
"priceRange": "$$$",
"rating": 4.0,
"location": {
"lat": 40.7282,
"lon": -73.9876
},
"description": "Vibrant Indian restaurant offering spicy curries and tandoori specialties."
}
]
Scenario 3: Find Nearby Restaurants with Pagination
Goal: Find restaurants within 10km of a location (lat: 40.7128, lon: -74.0060), with 2 results per page, starting from page 0.
API Call:
curl "http://localhost:8080/api/restaurants/nearby?lat=40.7128&lon=-74.0060&distance=10km&page=0&size=2"
Response:
{
"content": [
{
"id": "1",
"name": "Ocean Breeze Cafe",
"cuisine": "Italian",
"priceRange": "$$",
"rating": 4.5,
"location": {
"lat": 40.7128,
"lon": -74.0060
},
"description": "Cozy Italian cafe with a view of the ocean, serving authentic pasta and pizza."
},
{
"id": "3",
"name": "Sushi Haven",
"cuisine": "Japanese",
"priceRange": "$$",
"rating": 4.8,
"location": {
"lat": 40.7050,
"lon": -74.0100
},
"description": "Modern Japanese restaurant specializing in fresh sushi and sashimi."
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 2,
"offset": 0,
"sort": {
"sorted": false,
"unsorted": true,
"empty": true
}
},
"totalPages": 2,
"totalElements": 3,
"size": 2,
"number": 0,
"numberOfElements": 2,
"first": true,
"last": false,
"empty": false
}
Step 7: Advanced Features and Optimizations
Adding Full-Text Search
To enable full-text search across name and description, extend the repository with a custom query:
@Query("{\"multi_match\": {\"query\": \"?0\", \"fields\": [\"name^2\", \"description\"], \"fuzziness\": \"AUTO\"}}}")
List<Restaurant> searchByText(String query);
Update the controller:
@GetMapping("/text-search")
public List<Restaurant> textSearch(@RequestParam String query) {
return repository.searchByText(query);
}
Example Call:
curl "http://localhost:8080/api/restaurants/text-search?query=pasta"
This searches for "pasta" in name and description, with name weighted higher (^2).
Handling Large Datasets
For large datasets, optimize performance:
Pagination: Always use Pageable for endpoints returning many results.
Index Settings: Configure the index for performance:
@Document(indexName = "hotel_restaurants", shards = 5, replicas = 2)
Adjust shards and replicas based on your cluster size.
Query Caching: Elasticsearch caches frequently used queries automatically, but avoid overly complex queries.
Error Handling
Add exception handling to the controller:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception e) {
return new ResponseEntity<>("An error occurred: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Security Considerations
Secure Elasticsearch: Enable authentication (X-Pack) in production and configure credentials in application.properties.
API Security: Use Spring Security to add authentication/authorization to your REST APIs.
Input Validation: Validate query parameters to prevent injection attacks.
Step 8: Running and Testing the Application
Start Elasticsearch:
docker run -d --name elasticsearch -p 9200:9200 -e "discovery.type=single-node" elasticsearch:8.15.0
Run the Spring Boot Application:
mvn spring-boot:run
Test Endpoints: Use Postman or curl to test the /search, /autocomplete, and /nearby endpoints as shown above.
Monitor Elasticsearch: Use tools like Kibana or the Elasticsearch REST API (GET /_cat/indices) to verify the hotel_restaurants index and its data.
Step 9: Challenges and Solutions
Challenge: Slow query performance with large datasets. Solution: Optimize mappings (use Keyword for exact matches), enable pagination, and scale Elasticsearch with more nodes.
Challenge: Inaccurate autocomplete results. Solution: Use a dedicated completion suggester field in the mapping for better autocomplete performance.
Challenge: Connection issues with Elasticsearch. Solution: Check application.properties, ensure the Elasticsearch server is running, and verify network connectivity.
Step 10: Benefits of This Approach
Speed and Scalability: Elasticsearch’s distributed nature ensures fast searches, even with millions of records.
Flexibility: Combine full-text search, structured filters, and geospatial queries seamlessly.
Developer-Friendly: Spring Data Elasticsearch reduces boilerplate, and Spring Boot simplifies API development.
Extensibility: Easily add features like sorting, aggregations (e.g., average rating by cuisine), or machine learning-based relevance.
Conclusion
This guide demonstrated how to build a robust hotel restaurant search backend using Elasticsearch and Spring Boot. By leveraging Spring Data Elasticsearch, you can create a scalable, feature-rich API that supports full-text search, filtering, autocomplete, and geospatial queries. The provided example is a solid foundation that you can extend with features like advanced aggregations, personalized recommendations, or integration with a front-end application.
Subscribe to my newsletter
Read articles from Prathamesh Karatkar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
