DEV Community

Cover image for Implementing the Repository Design Pattern in Node.js & Express: A Complete Guide
Shamsuddeen Omacy
Shamsuddeen Omacy

Posted on • Originally published at omacy.Medium

Implementing the Repository Design Pattern in Node.js & Express: A Complete Guide

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
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install express mongoose dotenv
npm install -D nodemon
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

Environment Configuration
.env

PORT=3000
MONGODB_URI=mongodb://localhost:27017/taskmanager
package.json (add scripts)
{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Benefits Demonstrated

  1. 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
}
Enter fullscreen mode Exit fullscreen mode

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()
};
Enter fullscreen mode Exit fullscreen mode

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' }
      });
    }
}
Enter fullscreen mode Exit fullscreen mode

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)