Written by Chimezie Enyinnaya✏️
The repository pattern can be defined as an abstraction over data storage, allowing the decoupling of data access logic from the business logic. The repository pattern has lots of benefits:
- It enforces the dependency inversion principle
- Since business logic and data access logic are loosely coupled, they can be tested separately
- It helps with keeping the code structured and organized
- It reduces duplication of code and enhances code maintainability
Like most design patterns, the repository pattern is language agnostic. With that in mind, I’ll be showing how to implement the repository with TypeScript and Node.js. For the purpose of demonstration, I’ll be using Nest, a Node.js framework.
- Getting started
- Implementing the repository pattern
- Refactoring the repository contract to use generics
Getting started
Create a new Nest app
Like I said earlier, we’ll be using the Nest framework. So, let’s start by creating a fresh Nest application.
First, install the Nest CLI if you don’t already have it installed:
npm install -g @nestjs/cli
Once installed, we can use the CLI to create a new Nest application:
nest new nest-repository-pattern
To demonstrate the repository pattern, we’ll be using the concept of post
. Let’s create the module and controller for it:
nest generate module post
nest generate controller post --no-spec
These commands will generate a post.module.ts
file and a post.controller.ts
file respectively inside a post
directory within the src
directory.
Database setup
Next, let’s set up the database for our newly created Nest application. I’ll be using PostgreSQL, but you can use any of the databases Knex supports. To interact with our database, we’ll be using Objection.js, which is an ORM for Node.js built on top Knex. For this tutorial, we’ll be using Nest Objection, a Nest module for Objection.
So, let’s install all the necessary dependencies:
npm install knex objection @willsoto/nestjs-objection pg
Once installed, we can register the Nest Objection module inside the imports
array of src/app.module.ts
and pass along our database details:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ObjectionModule } from '@willsoto/nestjs-objection';
@Module({
imports: [
ObjectionModule.register({
config: {
client: 'pg',
useNullAsDefault: true,
connection: {
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: '',
database: 'nest-repository-pattern',
},
},
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Model and migration
To demonstrate the repository pattern, we’ll be using the concept of post. So let’s create a Post
model and the corresponding migration to create a posts
table.
Let’s start with the model. Inside the post
directory, create a new post.model.ts
file and paste the following code in it:
// src/post/post.model.ts
import { Model } from 'objection';
export default class Post extends Model {
static tableName = 'posts';
id: number;
title: string;
content: string;
}
The Post
model extends the base model from Objection. Then we define the name of the table that this model will use. Lastly, we define the columns that the table will have and their types.
Next, let’s register the model with the PostModule
by updating the module as below:
// src/post/post.module.ts
import { Module } from '@nestjs/common';
import { ObjectionModule } from '@willsoto/nestjs-objection';
import { PostController } from './post.controller';
import Post from './post.model';
@Module({
imports: [ObjectionModule.forFeature([Post])],
controllers: [PostController],
})
export class PostModule {}
Let’s create the migration for the posts
table. Before we can create migrations, our project needs to have a knexfile
, and we can create the file by running the command below:
npx knex init -x ts
By default the init
command will create a knexfile.js
, but since we are working with TypeScript, passing -x ts
will instruct the init
command to create a knexfile.ts
instead. The file will created in the root of the project. Then, we replace it content with the following:
// knexfile.ts
import type { Knex } from 'knex';
const config: { [key: string]: Knex.Config } = {
development: {
client: 'pg',
connection: {
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: '',
database: 'nest-repository-pattern',
},
migrations: {
directory: './src/database/migrations',
},
},
};
module.exports = config;
Ideally, you might want to have different configurations for different environments (development, staging, production, etc.), but for the purpose of this tutorial, I have only added the configuration for the development environment. In addition to the database config details, we also specified the directory where migrations will reside.
Now, we can create the migration for the posts
table:
npx knex migrate:make create_posts_table
As specified in knexfile.js
, the migration will be created inside src/database/migrations
. Open it and update it as below:
// src/database/migrations/TIMESTAMP_create_posts_table.ts import { Knex } from 'knex'; export async function up(knex: Knex): Promise { return knex.schema.createTable('posts', function (table) { table.increments('id'); table.string('title').notNullable(); table.text('content').notNullable(); }); } export async function down(knex: Knex): Promise { return knex.schema.dropTable('posts'); }
In the up
function, we are creating a posts
table in our database with three columns: id
, title
, and content
. The up
function will be executed when we run the migration. Then inside the down
function, we are simply dropping the posts
table that might have been created. The down
function will be executed when we roll back the migration.
Finally, let’s run the migration:
npx knex migrate:up
Implementing the repository pattern
Now, let’s get to the meat of this tutorial. The repository pattern makes use of the concept of contracts (interface) and concrete implementations. Basically, we define contracts/interfaces that we would want a concrete implementation (class) to adhere to.
Creating the repository contract
Having said that, let’s create the post contract/interface. Inside src
, create a new repositories
directory. This is where we’ll store all our repositories. Inside the newly created directory, create a PostRepositoryInterface.ts
file with the following content:
// src/repositories/PostRepositoryInterface.ts
import Post from '../post/post.model';
export default interface PostRepositoryInterface {
all(): Promise<Post[]>;
find(id: number): Promise<Post>;
create(data: object): Promise<Post>;
}
This is the contract we want all our post concrete implementation to adhere to. To keep things simple and straightforward, I have only added three methods.
Creating the concrete implementation
Next, let’s create the concrete implementation. Since our application current uses Knex to interact with the database, this will be the Knex implementation. Still inside the repositories
directory, create a new KnexPostRepository.ts
file with the following content:
// src/repositories/KnexPostRepository.ts
import { Inject } from '@nestjs/common';
import Post from 'src/post/post.model';
import PostRepositoryInterface from './PostRepositoryInterface';
export default class KnexPostRepository implements PostRepositoryInterface {
constructor(@Inject(Post) private readonly postModel: typeof Post) {}
async all(): Promise<Post[]> {
return this.postModel.query();
}
async find(id: number): Promise<Post> {
return this.postModel.query().where('id', id).first();
}
async create(data: object): Promise<Post> {
return this.postModel.query().insert(data);
}
}
The KnexPostRepository
class implements the PostRepositoryInterface
we created earlier, and so it therefore must adhere to the terms of the contract; that is, implement those methods defined in the interface. Inside the class constructor, we inject the Post
model into the class. Since we now have access to the Post
model, we can use it to perform the necessary operations in the respective methods.
Using the repository
Now, to use the KnexPostRepository
we just created, we need to first register with the Nest IoC container. We can do that by adding it to the providers
array of the PostModule
:
// src/post/post.module.ts
...
import KnexPostRepository from 'src/repositories/KnexPostRepository';
@Module({
...
providers: [
{ provide: 'PostRepository', useClass: KnexPostRepository },
],
...
})
export class PostModule {}
Inside the providers
array, we are saying, “Hey, Nest, we want the PostRepository
token to resolve to the KnexPostRepository
class.” In so doing, whenever we inject PostRepository
(more on this shortly), we will get an instance of KnexPostRepository
.
Now, let’s actually make use of the repository. Update the PostController
:
// src/post/post.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';
import PostRepositoryInterface from 'src/repositories/PostRepositoryInterface';
import Post from './post.model';
@Controller('post')
export class PostController {
constructor(
@Inject('PostRepository')
private readonly postRepository: PostRepositoryInterface,
) {}
@Get()
async findAll() {
return this.postRepository.all();
}
}
The magic happens inside the constructor. Remember the PostRepository
token from above? We inject it into the controller through its constructor, and as a result the postRepository
property will be an instance of KnexPostRepository
as explained above. Then we can conveniently use any of the methods defined in the repository.
That’s how to use the repository. If down the line in the course of the project we decide to switch data access layer to something like Prisma, we’ll just need to create a PrismaPostRepository
class that implements the PostRepositoryInterface
:
// src/repositories/PrismaPostRepository.ts
import PostRepositoryInterface from './PostRepositoryInterface';
export default class PrismaPostRepository implements PostRepositoryInterface {
async all(): Promise<Post[]> {
// Prisma logic
}
async find(id: number): Promise<Post> {
// Prisma logic
}
async create(data: object): Promise<Post> {
// Prisma logic
}
}
Then simply register in with the Nest IoC container:
// src/post/post.module.ts
...
import KnexPostRepository from 'src/repositories/KnexPostRepository';
@Module({
...
providers: [
// { provide: 'PostRepository', useClass: KnexPostRepository },
{ provide: 'PostRepository', useClass: PrismaPostRepository },
],
...
})
export class PostModule {}
The controller code will mostly remain the same.
Refactoring the repository contract to use generics
As it stands, we have successfully implemented the repository pattern and we can simply call it a day at this point. You’ll notice something though: PostRepositoryInterface
is tightly coupled with the Post
model, which isn’t a problem, per se, but imagine we want to add commenting functionality to our application. We might lean towards creating a CommentRepositoryInterface
that will have the same methods and structure as PostRepositoryInterface
. Then, we’ll create a KnexCommentRepository
that will implement CommentRepositoryInterface
.
You can immediately see the pattern of code duplication because PostRepositoryInterface
and CommentRepositoryInterface
are basically the same with just different models. So we are going to refactor the interface such that it can be reusable with any models.
We need a way to pass the model to the interface. Luckily for us, we can easily achieve that using TypeScript generics.
We are going to rename PostRepositoryInterface
to RepositoryInterface
and update the code:
// src/repositories/RepositoryInterface.ts
export default interface RepositoryInterface<T> {
all(): Promise<T[]>;
find(id: number): Promise<T>;
create(data: object): Promise<T>;
}
Here, T
will be the model that the concrete implementation is for. Any class that wants to make use of this interface must pass to it the model to fully adhere to the contract.
Now, we can make a slight adjustment to KnexPostRepository
by passing the Post
model to the RepositoryInterface
:
// src/repositories/KnexPostRepository.ts
import RepositoryInterface from './RepositoryInterface';
export default class KnexPostRepository implements RepositoryInterface<Post> {
// rest of the code remain the same
}
Then, KnexCommentRepository
can look like this:
// src/repositories/KnexCommentRepository.ts
import RepositoryInterface from './RepositoryInterface';
export default class KnexCommentRepository implements RepositoryInterface<Comment> {
// methods implementation for commenting functionality
}
Conclusion
In this tutorial, we learned about the repository pattern, some of its benefits, and how to implement the repository pattern with TypeScript and Node.js. Also, we saw how to reduce code duplication using TypeScript generics.
You can get the complete source code for our demo from this GitHub repository.
200’s only Monitor failed and slow network requests in production
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Top comments (0)