In this post, we’ll walk through a NestJS application that tracks your favorite movie titles, which also happen to be the set of movie titles uploaded to Kaggle by Cornell University in their Movie Dialog Corpus.
Throughout this series, we'll walk through some of the problems that you'll encounter as you deploy and manage a growing search database: things like keeping your search database in-sync with your relational database, creating pipelines, and more.
If you're here, you already know that search is essential to delivering a fast and intuitive experience for your users. Whether you're handling product catalogs, user-generated content, or large datasets, adding search to your NestJS application can take your app to the next level. Finding the right tools to get started can be a little time-consuming, though, so we're working on a series of tutorials to help navigate the path from zero to one.
We're going to start at day 0: demonstrating how to easily integrate Elasticsearch or OpenSearch into your NestJS app and seed it with a static dataset from your application.
Today, we'll be working with a back-end Node.js application built with the progressive NestJS framework. For those unfamiliar, NestJS has grown in popularity in-part due to its opinionated and modular nature. While we'll cover other back-end frameworks in the future, Nest's opinionated nature also lends itself to being a fantastic starting point for our series.
The Bonsai Examples repository may contain other code examples! Feel free to take a look at them, for this tutorial, we'll be spending our time within the back-end/nestjs directory.
Once you've cloned the repository, check out the <span class="inline-code"><pre><code>nestjs/pre-search</code></pre></span> branch - we'll work our way forwards to parity with the `nestjs/with-elasticsearch branch in the coming sections!
# Fetch the remote git branches
git fetch
# Switch to the nestjs/pre-search branch
git switch nestjs/pre-search
Finally, enter the <span class="inline-code"><pre><code>back-end/nestjs</code></pre></span> directory: this appropriately named directory is where we'll spend the duration of the tutorial in!
A tour of the movies module
Our simple application is primarily, and only, responsible for serving up details about our favorite movies. We can find an individual movie's metadata if we know its exact title, or we can see all of the movies' data if we paginate over a series of responses.
Info
For our application, we used a popular NestJS Boilerplate as our base, which implements a Hexagonal Architecture, which plays well with NestJS' opinionated modularity.
The code responsible for handling this functionality lives within the movies module.
What we're interested in, specifically, is the fact that our <span class="inline-code"><pre><code>movies.controller.ts</code></pre></span> currently only supports two actions to fetch our movies:
Get by <span class="inline-code"><pre><code>title</code></pre></span> (<span class="inline-code"><pre><code>GET /api/v1/movies/:title</code></pre></span>)
Get all movies (<span class="inline-code"><pre><code>GET /api/v1/movies</code></pre></span>)
Our goal is simple, yet powerful: augment our application to allow us to search for a partial title match!
Set up and run the application locally
Now that we've got a little familiarity with the application's structure, let's go ahead and configure our development environment and start running the development server.
First, we'll need to copy the <span class="inline-code"><pre><code>env-example</code></pre></span> file to <span class="inline-code"><pre><code>.env</code></pre></span>:
Now we're ready to run a PostgreSQL database locally, via docker
# To run this detached, run `docker compose up -d postgres`, but beware of silent failures!
docker compose up postgres
Open another terminal, and run the database migrations:
npm run migration:run
Seed the database with our favorite movie titles. We'll revisit this piece in the next section!
npm run seed:run:relational
And finally, start our development server:
npm run start:dev
Phew! After all of that, you should see terminal output similar to:
To confirm that your application is serving up the data we expect, try issuing a request to list a page of all of our favorite movies:
curl localhost:3000/api/v1/movies
You should see a response like the following:
Which is great, because I need to pull up the details on a really great movie. But, I just can't seem to find it via exact-search. I think it's called, "The Third Element":
We'll see that it's either not there (unlikely), or I've misremembered the name (likely):
If only there was a way to get a search integrated into my application to help me remember some of my favorite movie titles...
Adding Elasticsearch/OpenSearch
With our application running, it's time to supercharge our application with a bit of search magic.
Setting up a local Elasticsearch/OpenSearch cluster
We'll be using Docker again to host the latest addition to our application stack: Elasticsearch.
Tip
If you're still running the PostgreSQL container from the previous section, now would be a great time to shut it down by sending SIGINT (ctrl + c) to the window running the container!
From the following snippet of code, add the Elasticsearch service definition to your <span class="inline-code"><pre><code>docker-compose.yml</code></pre></span> file:
# Apache License v2.0, January 2004
# Courtesy of the CNCF project, Jaeger @ https://github.com/jaegertracing/jaeger
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.2
environment:
- discovery.type=single-node
- http.host=0.0.0.0
- transport.host=127.0.0.1
- xpack.security.enabled=false # Disable security features
- xpack.security.http.ssl.enabled=false # Disable HTTPS
- action.destructive_requires_name=false
- xpack.monitoring.collection.enabled=false # Disable monitoring features
ports:
- "9200:9200"
Tip
We're using Elasticsearch version 7.10.2, as it's the last fully open-sourced version available! For additional functionality, we recommend migrating to the current version of OpenSearch!
Once you've saved that file, start both the <span class="inline-code"><pre><code>postgres</code></pre></span> and <span class="inline-code"><pre><code>elasticsearch</code></pre></span> services:
docker compose up postgres elasticsearch
And add these environment variables to your <span class="inline-code"><pre><code>.env</code></pre></span> file:
Since, at this stage of our application, we know all of our favorite movies ahead-of-time, we're going to pursue a straight-forward approach to synchronizing our search and relational databases: after we've inserted all of our favorite movies into the relational database, we'll turn-around and insert them into our search database as well - the catch being that they'll hold the same <span class="inline-code"><pre><code>id</code></pre></span> attribute in both databases, making our search query results directly referential in our relational database!
Note
This particular search service structure is inspired by the Nest Stackter starter template, which we also evaluated for this post!
If you're familiar with NestJS, you likely won't be daunted by the number of files touched below.
If you're less familiar, please note that although there are a lot of changing files, many of them really only exist primarily to support modularity, document business logic (like query and return types), and type safety.
Configuring Elasticsearch in the NestJS Application
Before we move into code, let's add a couple of relevant dependencies to our application:
And, we'll add relevant Elasticsearch connection configuration in <span class="inline-code"><pre><code>src/database/</code></pre></span>, alongside our PostgreSQL configuration, by creating a few files:
// src/database/search/search.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ElasticsearchModule } from '@nestjs/elasticsearch';
import { elasticSearchModuleOptions } from '../config/search.config';
@Module({
imports: [
ConfigModule,
ElasticsearchModule.registerAsync(elasticSearchModuleOptions),
],
exports: [ElasticsearchModule],
})
export class SearchModule {}
And, last but not least for configuration, we'll need to add the search configuration to our App's module:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { MoviesModule } from './movies/movies.module';
import databaseConfig from './database/config/database.config';
import searchConfig from './database/config/search.config';
import appConfig from './config/app.config';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeOrmConfigService } from './database/typeorm-config.service';
import { HomeModule } from './home/home.module';
import { DataSource, DataSourceOptions } from 'typeorm';
import { SearchModule } from './database/search/search.module';
const infrastructureDatabaseModule = TypeOrmModule.forRootAsync({
useClass: TypeOrmConfigService,
dataSourceFactory: async (options: DataSourceOptions) => {
return new DataSource(options).initialize();
},
});
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, searchConfig, appConfig],
envFilePath: ['.env'],
}),
infrastructureDatabaseModule,
MoviesModule,
HomeModule,
SearchModule,
],
})
export class AppModule {}
Using Elasticsearch in the NestJS Application
Now that our application is configured to use Elasticsearch, we can get start writing in our search features. We'll begin by adding an interface that describes our movie-search-response items, in <span class="inline-text">src/movies/interfaces/movie-search-document.interface.ts</span>.
Followed by a <span class="inline-code"><pre><code>MoviesSearchService</code></pre></span> service in <span class="inline-text">src/movies/movies-search.service.ts</span> file, which will encapsulate our application's interactions with the search database:
The <span class="inline-code"><pre><code>MoviesSearchService</code></pre></span> currently provides a just a few of functions that will enable us to load our search database with movies, along with some others that will help us determine the status of our index:
<span class="inline-code"><pre><code>createIndex</code></pre></span>: to create our new inex.
<span class="inline-code"><pre><code>indexMovies</code></pre></span>: to bulk-add movies to the search index.
<span class="inline-code"><pre><code>count</code></pre></span>: to discover how many matches we have for a given query.
<span class="inline-code"><pre><code>search</code></pre></span>: to search for our document, with optional arguments like document offset, etc.
<span class="inline-code"><pre><code>existsIndex</code></pre></span>, <span class="inline-code"><pre><code>statusIndex</code></pre></span>: to determine whether the index exists, and its status.
To use this new functionality, we'll take a detour out of our NestJS application modules, and head over to <span class="inline-code"><pre><code>src/database/seeds/</code></pre></span>, where we'll create a new directory: <span class="inline-code"><pre><code>search</code></pre></span>.
This new <span class="inline-code"><pre><code>search</code></pre></span> directory will hold the scripts responsible for seeding our search database with indexes after we've loaded our relational database with movies.
Tip
You've probably noted that this approach isn't very robust to handling, say, a change in our favorite movie tastes, and you're absolutely right! We'll discover more advanced ways to work with Elasticsearch and OpenSearch, along with various trade-offs to those approaches, in coming posts!
We'll be adding similar files as to what's contained in the <span class="inline-text">src/database/seeds/relational</span> directory, so let's also create a sub-directory within <span class="inline-code"><pre><code>search</code></pre></span>: <span class="inline-text">src/database/seeds/search/movie</span>.
Add these files in:
<span class="inline-text">src/database/seeds/search/run-seed.ts</span> - this file orchestrates our search-seeding routines
// src/database/seeds/search/movie/movie-seed.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MovieEntity } from '../../../../movies/infrastructure/persistence/relational/entities/movie.entity';
import { MoviesSearchService } from '../../../../movies/movies-search.service';
@Injectable()
export class MovieSeedService {
constructor(
@InjectRepository(MovieEntity)
private repository: Repository,
private moviesSearchService: MoviesSearchService,
) {}
async run() {
// If index doesn't exist, create it and seed the database
const exists = await this.moviesSearchService.existsIndex();
if (!exists.body) {
await this.moviesSearchService.createIndex();
await this.moviesSearchService.statusIndex();
const insertedMovies = await this.repository.find({});
await this.moviesSearchService.indexMovies(insertedMovies);
}
}
}
To support the newly used <span class="inline-code"><pre><code>find</code></pre></span> method, we'll add that method into our <span class="inline-code"><pre><code>MovieRepository</code></pre></span>:
Add the abstract definition to <span class="inline-text">src/movies/infrastructure/persistence/movie.repository.ts</span>:
2. And add the <span class="inline-text">implementation</span> to <span class="inline-text">`src/movies/infrastructure/persistence/relational/repositories/movie.repository.ts`</span>:
And we'll configure the <span class="inline-code"><pre><code>MovieSeedModule</code></pre></span> located at <span class="inline-text">src/database/seeds/search/movie/movie-seed.module.ts</span>
// src/database/seeds/search/movie/movie-seed.module.ts
import { Module } from '@nestjs/common';
import { MovieSeedService } from './movie-seed.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MovieEntity } from '../../../../movies/infrastructure/persistence/relational/entities/movie.entity';
import { MoviesModule } from '../../../../movies/movies.module';
@Module({
imports: [TypeOrmModule.forFeature([MovieEntity]), MoviesModule],
providers: [MovieSeedService],
exports: [MovieSeedService],
})
export class MovieSeedModule {}
Once we've added this, we'll also add a command-line interface command to our <span class="inline-code"><pre><code>package.json</code></pre></span>, allowing us to trigger the seeding of our search database from the command-line: add the following line to the <span class="inline-code"><pre><code>scripts</code></pre></span> section of your <span class="inline-code"><pre><code>package.json</code></pre></span>:
Now that our search database is loaded-up with our favorite movie details, we're ready to create a public interface to that functionality in our application!
Re-open the <span class="inline-code"><pre><code>MoviesService</code></pre></span> service in the <span class="inline-code"><pre><code>src/movies/movies.service.ts</code></pre></span> file, because we're going to be adding one new, important, service:
<span class="inline-code"><pre><code>search</code></pre></span>: to, well, search for a movie!
You'll note that the updated <span class="inline-code"><pre><code>MoviesService</code></pre></span> class has a new constructor and some additional imports - update yours to match the below.
Warning
Some functions have been excluded from this snippet for brevity; make sure that if you copy and paste the snippet into your file, you keep the existing function definitions in the original file!
Next, since we want to provide the option of using a search query, but we still want our movies index response to be paginated either way, the query and response types will need to be tweaked slightly:
In the <span class="inline-text">src/utils/dto/infinity-pagination-response.dto.ts</span> file, we'll need to make the <span class="inline-code"><pre><code>hasNextPage</code></pre></span> member optional. Update that entry to reflect this diff (add the question mark in <span class="inline-code"><pre><code>hasNextPage?:</code></pre></span>)
Then, we'll update our <span class="inline-code"><pre><code>QueryMovieDto</code></pre></span> in <span class="inline-text">src/movies/dto/query-movie.ts</span>, which handles pagination options. We're going to add support for an offset-based pagination for Elasticsearch queries! Update your <span class="inline-code"><pre><code>QueryMovieDto</code></pre></span> definition to the below.
Warning
Some functions have been excluded from this snippet for brevity; make sure that if you copy and paste the snippet into your file, you keep the existing function definitions in the original file!
And finally, to handle this sort of pagination, we're going to return a new type for requests that hit the search database - one that contains the data and a count of the items returned. Create a new file, <span class="inline-text">src/utils/dto/search-response.dto.ts</span> with contents
// src/utils/dto/search-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNumber } from 'class-validator';
export class SearchResponseDto {
@ApiProperty()
@IsArray()
data: T[];
@ApiProperty()
@IsNumber()
count: number;
}
Finally, we're ready to update our controller's <span class="inline-code"><pre><code>findAll method</code></pre></span>, which is responsible for the index action, and now - search queries! Ensure your method looks like the below, including the annotations!
Warning
Some functions have been excluded from this snippet for brevity; make sure that if you copy and paste the snippet into your file, you keep the existing function definitions in the original file!
Next steps for your newly supercharged NestJS application
Now that you've added search to your NestJS Application, you're ready to deploy into production! Check out our post, "Heroku and Bonsai, a Winning Search Combination", for a detailed tutorial on how to launch this application onto Heroku, using the Bonsai Heroku Addon!
There are a number of day-1 and day-2 features that we'll want to add into our application to get the most out of the newly-added search capabilities that we'll cover in future posts, so stay tuned for part 2 of this series!
By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.