Express Repository Pattern
In this tutorial, we'll explore how to implement the Repository Pattern in Express applications. The Repository Pattern is a powerful architectural pattern that separates your data access logic from your business logic, making your Express applications more maintainable, testable, and scalable.
What is the Repository Pattern?
The Repository Pattern is a design pattern that isolates the data layer from the rest of the application. It acts as an intermediary between your application's business logic and the data source (like a database). Instead of directly accessing your database throughout your application, you create repository classes that encapsulate the logic needed to access that data.
Key benefits:
- Separation of concerns: Isolates data access logic from business logic
- Improved testability: Makes unit testing easier by allowing data access methods to be mocked
- Code reusability: Reduces duplication of data access code
- Flexibility: Makes it easier to switch between different data sources or databases
Basic Repository Pattern Structure in Express.js
Let's start by looking at the basic structure of the Repository Pattern in an Express application:
project-root/
├── src/
│ ├── controllers/
│ ├── models/
│ ├── repositories/ 👈 This is where our repositories live
│ ├── services/
│ └── routes/
├── app.js
└── package.json
Step-by-Step Implementation
1. Define Your Model
First, let's create a simple user model. This represents the data structure of our application.
// src/models/user.model.js
class User {
constructor(id, name, email, role) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}
}
module.exports = User;
2. Create the Repository Interface
Next, we'll define a repository interface that specifies the methods our repository should implement:
// src/repositories/repository.interface.js
class Repository {
findAll() {
throw new Error('Method not implemented');
}
findById(id) {
throw new Error('Method not implemented');
}
create(entity) {
throw new Error('Method not implemented');
}
update(id, entity) {
throw new Error('Method not implemented');
}
delete(id) {
throw new Error('Method not implemented');
}
}
module.exports = Repository;
3. Implement a Concrete Repository
Now we'll create a MongoDB implementation of our user repository:
// src/repositories/user.repository.js
const Repository = require('./repository.interface');
const User = require('../models/user.model');
const mongoose = require('mongoose');
// MongoDB schema for User
const UserSchema = new mongoose.Schema({
name: String,
email: String,
role: String
});
const UserModel = mongoose.model('User', UserSchema);
class UserRepository extends Repository {
async findAll() {
const users = await UserModel.find();
return users.map(user => new User(user._id, user.name, user.email, user.role));
}
async findById(id) {
const user = await UserModel.findById(id);
if (!user) return null;
return new User(user._id, user.name, user.email, user.role);
}
async create(userDetails) {
const user = new UserModel({
name: userDetails.name,
email: userDetails.email,
role: userDetails.role
});
const savedUser = await user.save();
return new User(savedUser._id, savedUser.name, savedUser.email, savedUser.role);
}
async update(id, userDetails) {
const updatedUser = await UserModel.findByIdAndUpdate(
id,
{
name: userDetails.name,
email: userDetails.email,
role: userDetails.role
},
{ new: true }
);
if (!updatedUser) return null;
return new User(updatedUser._id, updatedUser.name, updatedUser.email, updatedUser.role);
}
async delete(id) {
await UserModel.findByIdAndDelete(id);
return true;
}
}
module.exports = UserRepository;
4. Create a Service Layer (Optional but Recommended)
A service layer sits between controllers and repositories to handle business logic:
// src/services/user.service.js
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async getAllUsers() {
return await this.userRepository.findAll();
}
async getUserById(id) {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}
async createUser(userDetails) {
// Could include validation or business logic here
return await this.userRepository.create(userDetails);
}
async updateUser(id, userDetails) {
const updatedUser = await this.userRepository.update(id, userDetails);
if (!updatedUser) {
throw new Error('User not found');
}
return updatedUser;
}
async deleteUser(id) {
await this.userRepository.delete(id);
}
}
module.exports = UserService;
5. Implement Your Controller
Controllers handle HTTP requests and delegate business logic to services:
// src/controllers/user.controller.js
class UserController {
constructor(userService) {
this.userService = userService;
}
async getUsers(req, res, next) {
try {
const users = await this.userService.getAllUsers();
res.json(users);
} catch (error) {
next(error);
}
}
async getUserById(req, res, next) {
try {
const user = await this.userService.getUserById(req.params.id);
res.json(user);
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({ message: 'User not found' });
}
next(error);
}
}
async createUser(req, res, next) {
try {
const user = await this.userService.createUser(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
}
async updateUser(req, res, next) {
try {
const user = await this.userService.updateUser(req.params.id, req.body);
res.json(user);
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({ message: 'User not found' });
}
next(error);
}
}
async deleteUser(req, res, next) {
try {
await this.userService.deleteUser(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
}
}
module.exports = UserController;
6. Define Routes
Now let's connect our controller to Express routes:
// src/routes/user.routes.js
const express = require('express');
const router = express.Router();
// Import dependencies
const UserRepository = require('../repositories/user.repository');
const UserService = require('../services/user.service');
const UserController = require('../controllers/user.controller');
// Initialize dependencies
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const userController = new UserController(userService);
// Define routes
router.get('/', userController.getUsers.bind(userController));
router.get('/:id', userController.getUserById.bind(userController));
router.post('/', userController.createUser.bind(userController));
router.put('/:id', userController.updateUser.bind(userController));
router.delete('/:id', userController.deleteUser.bind(userController));
module.exports = router;
7. Configure Main App
Finally, let's put it all together:
// app.js
const express = require('express');
const mongoose = require('mongoose');
const userRoutes = require('./src/routes/user.routes');
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/mydatabase', {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
const app = express();
// Middleware
app.use(express.json());
// Routes
app.use('/api/users', userRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Creating a Memory Repository for Testing
One of the advantages of the Repository Pattern is that we can easily swap out implementations. Here's a simple in-memory repository for testing:
// src/repositories/user.memory.repository.js
const Repository = require('./repository.interface');
const User = require('../models/user.model');
class InMemoryUserRepository extends Repository {
constructor() {
super();
this.users = [];
this.currentId = 1;
}
async findAll() {
return [...this.users];
}
async findById(id) {
const user = this.users.find(u => u.id == id);
if (!user) return null;
return { ...user };
}
async create(userDetails) {
const newUser = new User(
this.currentId++,
userDetails.name,
userDetails.email,
userDetails.role
);
this.users.push(newUser);
return { ...newUser };
}
async update(id, userDetails) {
const index = this.users.findIndex(u => u.id == id);
if (index === -1) return null;
const updatedUser = {
...this.users[index],
name: userDetails.name,
email: userDetails.email,
role: userDetails.role
};
this.users[index] = updatedUser;
return { ...updatedUser };
}
async delete(id) {
const index = this.users.findIndex(u => u.id == id);
if (index !== -1) {
this.users.splice(index, 1);
}
return true;
}
}
module.exports = InMemoryUserRepository;
Dependency Injection with the Repository Pattern
To make our code even more flexible, we can use dependency injection. Here's how to set up a simple DI container:
// src/config/di-container.js
class DIContainer {
constructor() {
this.services = {};
}
register(name, instance) {
this.services[name] = instance;
}
get(name) {
if (!this.services[name]) {
throw new Error(`Service ${name} not found`);
}
return this.services[name];
}
}
// Set up the container
const container = new DIContainer();
// Register services
const UserRepository = require('../repositories/user.repository');
const UserService = require('../services/user.service');
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
container.register('userRepository', userRepository);
container.register('userService', userService);
module.exports = container;
Then we can use it in our routes:
// src/routes/user.routes.js
const express = require('express');
const router = express.Router();
const UserController = require('../controllers/user.controller');
const container = require('../config/di-container');
// Get dependencies from container
const userService = container.get('userService');
const userController = new UserController(userService);
// Define routes
router.get('/', userController.getUsers.bind(userController));
// ... other routes
module.exports = router;
Real-World Example: Multi-Database Support
Let's see how the Repository Pattern can help us support multiple databases:
// src/repositories/user.repository.factory.js
const MongoUserRepository = require('./user.repository');
const PostgresUserRepository = require('./user.postgres.repository');
const InMemoryUserRepository = require('./user.memory.repository');
class UserRepositoryFactory {
static create(type) {
switch (type) {
case 'mongodb':
return new MongoUserRepository();
case 'postgres':
return new PostgresUserRepository();
case 'memory':
return new InMemoryUserRepository();
default:
throw new Error(`Unsupported repository type: ${type}`);
}
}
}
module.exports = UserRepositoryFactory;
Now we can switch databases easily:
// app.js
const UserRepositoryFactory = require('./src/repositories/user.repository.factory');
const UserService = require('./src/services/user.service');
// Get repository type from environment variable
const repositoryType = process.env.DB_TYPE || 'mongodb';
const userRepository = UserRepositoryFactory.create(repositoryType);
const userService = new UserService(userRepository);
// ... rest of app setup
Summary
The Repository Pattern provides a clean way to organize data access in your Express applications. By implementing this pattern:
- Your business logic is separated from your data access logic
- You can easily swap out different database implementations
- Testing becomes much easier with mock repositories
- Your code is more modular and maintainable
This pattern works particularly well with Express.js, where you can organize your application into distinct layers: routes, controllers, services, and repositories. While it adds some initial complexity, the benefits in terms of maintainability and testability are significant for medium to large applications.
Additional Resources
- Martin Fowler on the Repository Pattern
- Domain-Driven Design - A book that popularized the Repository Pattern
- TypeORM - An ORM for TypeScript/JavaScript that has built-in repository support
- Sequelize - Another popular ORM with repository-like patterns
Exercises
- Implement a PostgreSQL version of the UserRepository
- Add validation in the UserService layer
- Write unit tests for the UserService using the in-memory repository
- Extend the repository to support filtering and pagination
- Implement caching in your repository to improve performance
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)