Repository Pattern in Node.js (5 Essential Tips)

When working on large-scale Node.js applications, developers often face problems maintaining clean separation between business logic and database operations. The Repository Design Pattern solves this by introducing a middle layer that isolates the data access logic, improving scalability, testability, and reusability.

This tutorial will guide you through implementing the Node.js Repository Design Pattern to enhance testability. We’ll implement this pattern using Node.js with Express.js and Mongoose, and see how it improves performance and developer productivity.

Why the Repository Design Pattern is Essential for Node.js: The Repository Pattern creates a necessary abstraction layer between your Business Logic (Service Layer) and your Data Access Logic (ORM/Database). This separation is the key to optimization, as it allows you to centralize performance-critical database code and simplify testing.

Pain Point – inherited spaghetti codebases and untestable Mongoose calls scattered everywhere: If you’ve ever inherited a Node.js codebase where Mongoose queries are scattered across dozens of controller files, you already know the real cost – a single schema change means hunting through the entire codebase, and writing a reliable unit test feels nearly impossible without spinning up a real database. This is the exact pain the Repository Pattern is designed to eliminate.


1. Project Setup and Folder Structure

First, set up your project using Express, TypeScript, and Mongoose (our ORM for MongoDB).

A. Initialization and Dependencies

Open your terminal and execute the following commands:

# 1. Initialize the project directory
mkdir node-repo-api && cd node-repo-api
npm init -y

# 2. Install main dependencies (Express, Mongoose, TypeScript)
npm install express typescript ts-node @types/express dotenv mongoose @types/mongoose

# 3. Install development dependencies
npm install -D typescript @types/node ts-node nodemon

# 4. Initialize TypeScript configuration
npx tsc --init --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --lib es2022 --module commonjs

B. The Clean Folder Structure

Create the following folder and file structure inside the root directory:

node-repo-api/
├── src/
│   ├── models/           # Mongoose Schema Definitions
│   │   └── user.model.ts
│   ├── domain/           # Repository Interfaces (The Contract)
│   │   └── IUserRepository.ts
│   ├── repositories/     # Concrete Database Logic (Mongoose)
│   │   └── UserRepository.ts
│   ├── services/         # Business Logic
│   │   └── UserService.ts
│   ├── controllers/      # HTTP Request/Response Handling
│   │   └── UserController.ts
│   ├── routes/           # Endpoint Definitions
│   │   └── user.routes.ts
│   └── app.ts            # Application Entry Point
├── package.json
└── tsconfig.json

2. The Model and the Repository Design Pattern Classes

The Repository Pattern provides a clean separation of concerns. We define a contract before writing the database code.

A. Model Definition (src/models/user.model.ts)

Define the Mongoose schema for the User entity.

import { Schema, model, Document, InferSchemaType } from 'mongoose';

const UserSchema = new Schema({
    name: { type: String, required: true, trim: true },
    email: { type: String, required: true, unique: true, lowercase: true, trim: true },
    isActive: { type: Boolean, default: true, index: true }, // Index defined directly in schema property
}, { 
    timestamps: true // Best Practice: Automatically tracks createdAt and updatedAt
});

export type User = InferSchemaType<typeof UserSchema>;
export interface UserDocument extends User, Document {}

const UserModel = model<UserDocument>('User', UserSchema);
export default UserModel;

B. Repository Interface (src/domain/IUserRepository.ts)

Define the contract that the Service layer will depend on.

import { UserDocument } from '../models/user.model';

export interface IUserRepository {
    findById(id: string): Promise<UserDocument | null>;
    findAllActive(): Promise<UserDocument[]>;
    create(data: Omit<UserDocument, '_id' | 'createdAt' | 'updatedAt'>): Promise<UserDocument>;
    update(id: string, data: Partial<UserDocument>): Promise<UserDocument | null>;
    
    // Bug Fix: Added 'findActivePaginated' here because it was implemented 
    // in the repository class but entirely missing from this interface contract.
    findActivePaginated(page: number, limit: number): Promise<UserDocument[]>;
}

C. Repository Implementation (src/repositories/UserRepository.ts)

Implement the interface, containing all Mongoose-specific code.

import { IUserRepository } from '../domain/IUserRepository';
import UserModel, { UserDocument } from '../models/user.model';

export class UserRepository implements IUserRepository {
    
    public async findById(id: string): Promise<UserDocument | null> {
        // Optimization: .lean() skips hydration for speed
        return await UserModel.findById(id).select('name email isActive').lean<UserDocument>();
    }

    public async findAllActive(): Promise<UserDocument[]> {
        return await UserModel.find({ isActive: true }).select('name email').lean<UserDocument[]>();
    }

    public async create(data: Omit<UserDocument, '_id' | 'createdAt' | 'updatedAt'>): Promise<UserDocument> {
        const user = await UserModel.create(data);
        // Optional: convert to plain object via .toObject() if consistency with .lean() is desired across methods
        return user.toObject(); 
    }

    public async update(id: string, data: Partial<UserDocument>): Promise<UserDocument | null> {
        return await UserModel.findByIdAndUpdate(id, data, { new: true }).lean<UserDocument>();
    }

    public async findActivePaginated(page: number, limit: number): Promise<UserDocument[]> {
        // Optimization: Ensure page numbers are safely floored/maxed if passed from external controllers
        const validPage = Math.max(1, page);
        const validLimit = Math.min(100, Math.max(1, limit)); // Cap limit to prevent heavy queries

        return await UserModel.find({ isActive: true })
            .select('name email')
            .skip((validPage - 1) * validLimit)
            .limit(validLimit)
            .lean<UserDocument[]>();
    }
}

3. Service and Controller Layers

These layers are now clean and database-agnostic.

A. Service Layer (src/services/UserService.ts)

Handles Business Logic and depends only on the IUserRepository interface.

import { IUserRepository } from '../domain/IUserRepository';
import { CreateUserDto } from '../dtos/CreateUser.dto'; // Type safety instead of 'any'
import { ConflictError } from '../errors/ConflictError'; // Custom error class

export class UserService {
    // TypeScript shorthand: automatically declares and assigns the private property
    constructor(private readonly userRepository: IUserRepository) {}

    public getActiveUsers() {
        // Optimization: Removed redundant 'await' since we are returning the promise directly
        return this.userRepository.findAllActive();
    }

    public async registerUser(userData: CreateUserDto) {
        try {
            const user = await this.userRepository.create(userData);
            // Non-blocking side effects (like queueing a welcome email) can go here
            return user;
        } catch (error: any) {
            // Catch database-specific unique constraint errors (e.g., MongoDB 11000 or Postgres 23505)
            // and throw a custom error for your centralized error handler to map to 409
            if (error.code === 11000 || error.code === '23505') {
                throw new ConflictError('User with this email already exists');
            }
            throw error; // Let other unexpected errors flow to the 500 handler
        }
    }
}

B. Controller Layer (src/controllers/UserController.ts)

Handles HTTP requests and delegates all work to the Service Layer.

import { Request, Response } from 'express';
import { UserService } from '../services/UserService';
import { asyncHandler } from '../utils/asyncHandler';

const userService = new UserService();

export class UserController {
    
    // Wrapped with asyncHandler - no try/catch needed!
    public static getActiveUsers = asyncHandler(async (req: Request, res: Response) => {
        const users = await userService.getActiveUsers();
        return res.status(200).json({ data: users });
    });

    public static createNewUser = asyncHandler(async (req: Request, res: Response) => {
        // Assume input validation middleware ran successfully
        const newUser = await userService.registerUser(req.body);
        
        // Optimization: Return a minimal payload (ID, email)
        return res.status(201).json({ id: newUser._id, email: newUser.email });
    });
}

4. Routing and Application Entry

This section connects the HTTP paths to the Controller methods.

A. Route Definition (src/routes/user.routes.ts)

import { Router } from 'express';
import { UserController } from '../controllers/UserController';

const router = Router();

// Route Logic: Mapping HTTP verbs and paths to Controller methods
router.get('/active', UserController.getActiveUsers);
router.post('/', UserController.createNewUser);

export default router;

B. Application Setup (src/app.ts)

Set up the Express server and register the routes. (You’ll need to set up a MongoDB connection for production, omitted here for brevity.)

import express from 'express';
import userRoutes from './routes/user.routes';
// import mongoose from 'mongoose'; // For real DB connection

const app = express();
const PORT = 3333;

app.use(express.json()); // Middleware for parsing JSON bodies

// Route Registration: All routes in userRoutes are prefixed with /api/v1/users
app.use('/api/v1/users', userRoutes);

app.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
    // mongoose.connect(process.env.MONGO_URI); // Example DB connection
});

5. Run and Test

Test your fully layered API using the terminal or a GUI client.

A. Setup for Development

Add a dev script to your package.json to automatically restart the server on code changes:

// package.json (inside "scripts" block)
"dev": "nodemon --exec ts-node src/app.ts"

Start the server:

npm run dev

B. Testing with cURL (Terminal/Bash)

The base URL for the User API is http://localhost:3333/api/v1/users.

Test 1: GET /api/v1/users/active

curl -X GET http://localhost:3333/api/v1/users/active

Expected Output: 200 OK and a JSON array of active users.

Test 2: POST /api/v1/users (Create)

# Note: Escaping double quotes is needed for Bash/CMD
curl -X POST http://localhost:3333/api/v1/users \
     -H "Content-Type: application/json" \
     -d '{"name": "Dmitri", "email": "dmitri@example.com"}'

Expected Output: 201 Created and a JSON object containing the new user’s id and email.

C. Testing with Postman (GUI)

  1. For POST:
    • Set HTTP Method to POST.
    • Set Request URL: http://localhost:3333/api/v1/users
    • Go to the Body tab, select raw, and choose JSON.
    • Paste the JSON data and click Send.
  2. For GET:
    • Set HTTP Method to GET.
    • Set Request URL: http://localhost:3333/api/v1/users/active
    • Click Send.

The pain point most teams hit after adopting this pattern: how do you unit test the Service layer without spinning up MongoDB? Since UserService depends only on the IUserRepository interface, you can inject a fully mocked repository in tests:

// tests/UserService.test.ts
import { UserService } from '../src/services/UserService';
import { IUserRepository } from '../src/domain/IUserRepository';

describe('UserService', () => {
    it('returns active users from the repository', async () => {
        const mockRepo: Partial<IUserRepository> = {
            findAllActive: jest.fn().mockResolvedValue([{ name: 'Dmitri', email: 'dmitri@example.com' }]),
        };

        const service = new UserService(mockRepo as IUserRepository);
        const result = await service.getActiveUsers();

        expect(result).toHaveLength(1);
        expect(mockRepo.findAllActive).toHaveBeenCalledTimes(1);
    });
});

No database connection, no test fixtures, no flaky integration tests; just a fast, isolated unit test. This is the real payoff of the Repository Pattern that most tutorials skip.


Why It’s Better?

CriteriaWithout RepositoryWith Repository
Code SeparationBusiness & DB logic mixedClearly separated
MaintainabilityDifficultEasy to scale
TestingComplex mocksSimple mock layer
PerformanceInefficientOptimized caching possibilities

By structuring your data access layer in Node.js and thus applying this clean architecture approach, you achieve better code quality and maintainability. As your Node.js backend grows, repositories allow you to switch databases, introduce caching (like Redis), or apply rate limits without refactoring core business logic.

Try extending this tutorial with Service Layers, DTOs, or Unit Tests (Jest) to achieve enterprise-level maintainability.

You’ll find some writings on clean software architecture in this section. Thank you for reading!


Useful Links

pmwithmizan
pmwithmizan

Scrum Master & Project Manager with 6+ years delivering software at scale across international teams. Certified ScrumMaster (CSM) with a proven record of 95% on-time delivery, 90% client satisfaction, and cycle time reductions of up to 30%. Experienced in coaching teams, scaling Agile practices, and aligning engineering delivery with business outcomes. Skilled at RAID governance, forecasting, backlog refinement, and stakeholder management. Technical foundation in PHP/JS stacks, AWS, and databases ensures clear translation of technical trade-offs into business decisions.

Leave a Reply