What's Covered:
Core Concepts:
- Clear explanation of what the Repository Pattern is
- Importance rating: 9/10 with detailed justification
- Complete separation of concerns across layers
Full Implementation:
- Model Layer - Mongoose schema with validation
- Repository Layer - All CRUD operations centralized
- Service Layer - Business logic separate from data access
- Controller Layer - HTTP request/response handling
- Routes - Clean API endpoint definitions
Introduction
The Repository Design Pattern is a crucial architectural pattern that acts as a bridge between your business logic and data access layer. In this comprehensive tutorial, we'll build a Task Management API using Node.js and Express to demonstrate how this pattern can transform your code quality and maintainability.
What is the Repository Pattern?
The Repository Pattern is a design pattern that creates an abstraction layer between the data access logic and the business logic of an application, thereby separating these two components. Think of it as a mediator that handles all communication with your data source (database, API, file system, etc.).
Key Concepts:
Abstraction: Hides the complexity of data access operations
Separation of Concerns: Business logic doesn't know about database implementation
Testability: Easy to mock repositories for unit testing
Flexibility: Switch databases without changing business logic
Importance Scale: 9/10
Why this high rating?
✅ Maintainability: Changes to data access logic are centralized.
✅ Testability: Mock repositories easily for unit tests
✅ Flexibility: Swap MongoDB for PostgreSQL without touching business logic
✅ DRY Principle: Eliminate repetitive database queries
✅ Team Collaboration: Clear boundaries between layers
✅ Error Handling: Centralized data access error management
Project Setup
Let's start by setting up our Task Management API:
mkdir taska
cd taska
npm init -y
Install dependencies:
npm install express mongoose dotenv
npm install -D nodemon
Project structure:
taska/
├── src/
│ ├── models/
│ │ └── task.model.js
│ ├── repositories/
│ │ └── task.repository.js
│ ├── services/
│ │ └── task.service.js
│ ├── controllers/
│ │ └── task.controller.js
│ ├── routes/
│ │ └── task.routes.js
│ └── app.js
├── .env
├── server.js
└── package.json
Step 1: Create the Task Model
The model defines the data structure.
src/models/task.model.js
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Task title is required'],
trim: true,
maxlength: [100, 'Title cannot exceed 100 characters']
},
description: {
type: String,
trim: true,
maxlength: [500, 'Description cannot exceed 500 characters']
},
status: {
type: String,
enum: ['pending', 'in-progress', 'completed'],
default: 'pending'
},
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium'
},
dueDate: {
type: Date
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
taskSchema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});
module.exports = mongoose.model('Task', taskSchema);
Step 2: Implement the Repository Layer
This is where the magic happens! The repository handles all database operations.
src/repositories/task.repository.js
const Task = require('../models/task.model');
class TaskRepository {
// CREATE - Add a new task
async create(taskData) {
try {
const task = new Task(taskData);
return await task.save();
} catch (error) {
throw new Error(`Error creating task: ${error.message}`);
}
}
// READ - Get all tasks with optional filtering
async findAll(filters = {}) {
try {
const query = {};
// Apply filters if provided
if (filters.status) query.status = filters.status;
if (filters.priority) query.priority = filters.priority;
return await Task.find(query).sort({ createdAt: -1 });
} catch (error) {
throw new Error(`Error fetching tasks: ${error.message}`);
}
}
// READ - Get a single task by ID
async findById(taskId) {
try {
const task = await Task.findById(taskId);
if (!task) {
throw new Error('Task not found');
}
return task;
} catch (error) {
throw new Error(`Error fetching task: ${error.message}`);
}
}
// READ - Find tasks by status
async findByStatus(status) {
try {
return await Task.find({ status }).sort({ createdAt: -1 });
} catch (error) {
throw new Error(`Error fetching tasks by status: ${error.message}`);
}
}
// UPDATE - Update a task by ID
async update(taskId, updateData) {
try {
const task = await Task.findByIdAndUpdate(
taskId,
{ ...updateData, updatedAt: Date.now() },
{ new: true, runValidators: true }
);
if (!task) {
throw new Error('Task not found');
}
return task;
} catch (error) {
throw new Error(`Error updating task: ${error.message}`);
}
}
// DELETE - Remove a task by ID
async delete(taskId) {
try {
const task = await Task.findByIdAndDelete(taskId);
if (!task) {
throw new Error('Task not found');
}
return task;
} catch (error) {
throw new Error(`Error deleting task: ${error.message}`);
}
}
// Additional utility methods
async count(filters = {}) {
try {
return await Task.countDocuments(filters);
} catch (error) {
throw new Error(`Error counting tasks: ${error.message}`);
}
}
async deleteAll() {
try {
return await Task.deleteMany({});
} catch (error) {
throw new Error(`Error deleting all tasks: ${error.message}`);
}
}
}
module.exports = new TaskRepository();
Step 3: Create the Service Layer
The service layer contains business logic and uses the repository.
src/services/task.service.js
const taskRepository = require('../repositories/task.repository');
class TaskService {
async createTask(taskData) {
// Business logic: validate due date
if (taskData.dueDate && new Date(taskData.dueDate) < new Date()) {
throw new Error('Due date cannot be in the past');
}
return await taskRepository.create(taskData);
}
async getAllTasks(filters) {
return await taskRepository.findAll(filters);
}
async getTaskById(taskId) {
return await taskRepository.findById(taskId);
}
async updateTask(taskId, updateData) {
// Business logic: prevent invalid status transitions
if (updateData.status === 'completed') {
const task = await taskRepository.findById(taskId);
if (task.status === 'pending') {
throw new Error('Cannot mark pending task as completed. Move to in-progress first.');
}
}
return await taskRepository.update(taskId, updateData);
}
async deleteTask(taskId) {
return await taskRepository.delete(taskId);
}
async getTaskStats() {
const total = await taskRepository.count();
const pending = await taskRepository.count({ status: 'pending' });
const inProgress = await taskRepository.count({ status: 'in-progress' });
const completed = await taskRepository.count({ status: 'completed' });
return {
total,
pending,
inProgress,
completed
};
}
}
module.exports = new TaskService();
Step 4: Build the Controller Layer
Controllers handle HTTP requests and responses.
src/controllers/task.controller.js
const taskService = require('../services/task.service');
class TaskController {
// CREATE a new task
async create(req, res) {
try {
const task = await taskService.createTask(req.body);
res.status(201).json({
success: true,
message: 'Task created successfully',
data: task
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
}
// READ all tasks
async getAll(req, res) {
try {
const { status, priority } = req.query;
const filters = {};
if (status) filters.status = status;
if (priority) filters.priority = priority;
const tasks = await taskService.getAllTasks(filters);
res.status(200).json({
success: true,
count: tasks.length,
data: tasks
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
}
// READ a single task
async getById(req, res) {
try {
const task = await taskService.getTaskById(req.params.id);
res.status(200).json({
success: true,
data: task
});
} catch (error) {
res.status(404).json({
success: false,
message: error.message
});
}
}
// UPDATE a task
async update(req, res) {
try {
const task = await taskService.updateTask(req.params.id, req.body);
res.status(200).json({
success: true,
message: 'Task updated successfully',
data: task
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
}
// DELETE a task
async delete(req, res) {
try {
await taskService.deleteTask(req.params.id);
res.status(200).json({
success: true,
message: 'Task deleted successfully'
});
} catch (error) {
res.status(404).json({
success: false,
message: error.message
});
}
}
// Get task statistics
async getStats(req, res) {
try {
const stats = await taskService.getTaskStats();
res.status(200).json({
success: true,
data: stats
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
}
}
module.exports = new TaskController();
Step 5: Define Routes
src/routes/task.routes.js
const express = require('express');
const router = express.Router();
const taskController = require('../controllers/task.controller');
// CRUD Routes
router.post('/', taskController.create);
router.get('/', taskController.getAll);
router.get('/stats', taskController.getStats);
router.get('/:id', taskController.getById);
router.put('/:id', taskController.update);
router.delete('/:id', taskController.delete);
module.exports = router;
Step 6: Set Up Express App
src/app.js
const express = require('express');
const taskRoutes = require('./routes/task.routes');
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/tasks', taskRoutes);
// Health check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK', message: 'Server is running' });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Something went wrong!',
error: err.message
});
});
module.exports = app;
Step 7: Create Server Entry Point
server.js
const mongoose = require('mongoose');
const app = require('./src/app');
require('dotenv').config();
const PORT = process.env.PORT || 3000;
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/taskmanager';
mongoose.connect(MONGODB_URI)
.then(() => {
console.log('✅ Connected to MongoDB');
app.listen(PORT, () => {
console.log(`🚀 Server is running on port ${PORT}`);
});
})
.catch((error) => {
console.error('❌ MongoDB connection error:', error);
process.exit(1);
});
Environment Configuration
.env
PORT=3000
MONGODB_URI=mongodb://localhost:27017/taskmanager
package.json (add scripts)
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}
Benefits Demonstrated
- Easy Database Switching
Want to switch from MongoDB to PostgreSQL? Just create a new repository:
task.repository.postgres.js
class TaskRepositoryPostgres {
async create(taskData) {
// PostgreSQL implementation
return await pool.query('INSERT INTO tasks...');
}
// ... other methods
}
Your service and controller layers remain unchanged!
2. Simplified Testing
Mock the repository for unit tests:
const mockRepository = {
create: jest.fn(),
findAll: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
3. Centralized Data Logic
All database queries are in one place. Need to add caching? Update the repository only.
4. Clear Separation
Model: Data structure
Repository: Data access
Service: Business logic
Controller: HTTP handling
Routes: Endpoint definitions
Advanced Patterns
Generic Repository
Create a base repository for common operations:
class BaseRepository {
constructor(model) {
this.model = model;
}
async create(data) {
return await this.model.create(data);
}
async findAll(filters = {}) {
return await this.model.find(filters);
}
async findById(id) {
return await this.model.findById(id);
}
async update(id, data) {
return await this.model.findByIdAndUpdate(id, data, { new: true });
}
async delete(id) {
return await this.model.findByIdAndDelete(id);
}
}
// Extend for specific repositories
class TaskRepository extends BaseRepository {
constructor() {
super(Task);
}
// Add task-specific methods
async findOverdueTasks() {
return await this.model.find({
dueDate: { $lt: new Date() },
status: { $ne: 'completed' }
});
}
}
Conclusion
The Repository Pattern is a powerful tool for building maintainable, testable, and scalable Node.js applications. By separating data access logic from business logic, you create a clean architecture that's easy to understand, modify, and extend.
Key Takeaways:
The Repository Pattern adds a layer between business logic and data access
- Makes code more testable and maintainable
- Enables easy database switching without affecting business logic
- Promotes clean code architecture and separation of concerns
- Scales well with team size and project complexity
Start implementing this pattern in your next Node.js project and experience the difference in code quality and maintainability!
Here is the link to the complete repo on GitHub
Top comments (0)