The Controller-Service-Repository Pattern

As an Express application grows, putting all logic inside route handlers becomes unmaintainable. A route handler that validates input, queries the database, applies business rules, sends emails, and formats responses is impossible to test in isolation and painful to change. The Controller-Service-Repository pattern solves this by separating the three distinct concerns present in every server-side operation: handling HTTP (controller), applying business rules (service), and persisting data (repository). This pattern is the foundation of professional Node.js architecture — understanding it is the difference between writing code that works and writing code that scales.

Layer Responsibilities

Layer Knows About Does NOT Know About Depends On
Controller HTTP — req, res, next Business rules, database queries Service layer
Service Business rules, application logic HTTP — no req/res, no Express Repository layer
Repository Database — Mongoose models, queries Business rules, HTTP Mongoose models
Model Data shape — schema, validation Anything above it Mongoose

What Belongs Where

Concern Layer Example
Parse request params / body Controller const { id } = req.params
Return HTTP responses Controller res.status(201).json(...)
Call next(err) on failure Controller next(err)
Validate business rules Service “A user can only have 100 active tasks”
Orchestrate multiple operations Service Create task + send notification + update stats
Send emails / push notifications Service emailService.sendTaskAssigned(task)
MongoDB queries — find, create, update Repository Task.find({ user: id })
Aggregation pipelines Repository Task.aggregate([...])
Schema definition, indexes Model taskSchema.index({...})
Note: The Repository layer might seem unnecessary — why not call Mongoose directly from the Service? The answer is testability and flexibility. A service that calls Task.findById(id) is tightly coupled to Mongoose. A service that calls taskRepository.findById(id) can be tested by swapping in a mock repository that returns predictable data — no real database required. It also makes switching databases (Mongoose to a different ODM, or MongoDB to PostgreSQL) a repository-layer change only, with no service layer modifications.
Tip: Keep controllers as thin as possible — ideally three lines: extract data from the request, call the service, send the response. Any logic beyond that belongs in the service layer. A controller that can be read in under 10 lines is a well-structured controller. A controller with 100 lines of business logic is a service in disguise and will cause problems when you try to test it.
Warning: The service layer must have zero knowledge of Express. If your service function has req or res in its signature, it is violating the separation of concerns. This matters because services should be callable from multiple contexts — HTTP controllers, WebSocket handlers, CLI scripts, background jobs, and unit tests — none of which have Express request or response objects.

Complete Three-Layer Implementation

// ── models/task.model.js ──────────────────────────────────────────────────
const mongoose   = require('mongoose');
const taskSchema = new mongoose.Schema({
    title:       { type: String, required: true, trim: true, maxlength: 200 },
    description: { type: String, maxlength: 2000 },
    priority:    { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
    status:      { type: String, enum: ['pending', 'in-progress', 'completed'], default: 'pending' },
    dueDate:     { type: Date },
    user:        { type: mongoose.Types.ObjectId, ref: 'User', required: true },
    completedAt: { type: Date },
}, { timestamps: true });

taskSchema.index({ user: 1, status: 1, createdAt: -1 });
module.exports = mongoose.model('Task', taskSchema);

// ── repositories/task.repository.js ──────────────────────────────────────
const Task = require('../models/task.model');

class TaskRepository {
    async findAll({ userId, status, priority, page = 1, limit = 10, sort = '-createdAt' }) {
        const filter = { user: userId, deletedAt: { $exists: false } };
        if (status)   filter.status   = status;
        if (priority) filter.priority = priority;

        const [data, total] = await Promise.all([
            Task.find(filter)
                .sort(sort)
                .skip((page - 1) * limit)
                .limit(limit)
                .select('-__v')
                .lean(),
            Task.countDocuments(filter),
        ]);
        return { data, total };
    }

    async findById(id, userId) {
        return Task.findOne({ _id: id, user: userId }).lean();
    }

    async findByUser(userId) {
        return Task.find({ user: userId }).lean();
    }

    async create(data) {
        return Task.create(data);
    }

    async update(id, userId, changes) {
        return Task.findOneAndUpdate(
            { _id: id, user: userId },
            { $set: changes },
            { new: true, runValidators: true }
        );
    }

    async delete(id, userId) {
        return Task.findOneAndDelete({ _id: id, user: userId });
    }

    async countByStatus(userId) {
        return Task.aggregate([
            { $match: { user: userId } },
            { $group: { _id: '$status', count: { $sum: 1 } } },
        ]);
    }

    async countActive(userId) {
        return Task.countDocuments({ user: userId, status: { $ne: 'completed' } });
    }
}

module.exports = new TaskRepository();

// ── services/task.service.js ──────────────────────────────────────────────
const taskRepository    = require('../repositories/task.repository');
const { NotFoundError, ForbiddenError, ValidationError } = require('../utils/AppError');

const MAX_ACTIVE_TASKS = 100;

class TaskService {
    async getAllTasks(userId, queryParams) {
        const { data, total } = await taskRepository.findAll({ userId, ...queryParams });
        return { tasks: data, total };
    }

    async getTaskById(taskId, userId) {
        const task = await taskRepository.findById(taskId, userId);
        if (!task) throw new NotFoundError('Task not found');
        return task;
    }

    async createTask(userId, taskData) {
        // Business rule: users cannot have more than 100 active tasks
        const activeCount = await taskRepository.countActive(userId);
        if (activeCount >= MAX_ACTIVE_TASKS) {
            throw new ValidationError(`Cannot create more than ${MAX_ACTIVE_TASKS} active tasks`);
        }

        // Business rule: due date must be in the future
        if (taskData.dueDate && new Date(taskData.dueDate) < new Date()) {
            throw new ValidationError('Due date must be in the future');
        }

        const task = await taskRepository.create({ ...taskData, user: userId });
        return task;
    }

    async updateTask(taskId, userId, changes) {
        // Business rule: cannot reopen a completed task to in-progress via update
        if (changes.status === 'in-progress') {
            const existing = await taskRepository.findById(taskId, userId);
            if (!existing) throw new NotFoundError('Task not found');
            if (existing.status === 'completed') {
                throw new ValidationError('Use the reopen endpoint to reopen a completed task');
            }
        }

        const task = await taskRepository.update(taskId, userId, changes);
        if (!task) throw new NotFoundError('Task not found');
        return task;
    }

    async markComplete(taskId, userId) {
        const task = await taskRepository.update(taskId, userId, {
            status:      'completed',
            completedAt: new Date(),
        });
        if (!task) throw new NotFoundError('Task not found');
        return task;
    }

    async deleteTask(taskId, userId) {
        const task = await taskRepository.delete(taskId, userId);
        if (!task) throw new NotFoundError('Task not found');
        return task;
    }

    async getStats(userId) {
        const statusCounts = await taskRepository.countByStatus(userId);
        return statusCounts.reduce((acc, { _id, count }) => {
            acc[_id] = count;
            return acc;
        }, { pending: 0, 'in-progress': 0, completed: 0 });
    }
}

module.exports = new TaskService();

// ── controllers/task.controller.js ────────────────────────────────────────
const taskService  = require('../services/task.service');
const asyncHandler = require('../utils/asyncHandler');

// Controller: extract from req → call service → send res
exports.getAll = asyncHandler(async (req, res) => {
    const { page, limit, sort, status, priority } = req.query;
    const { tasks, total } = await taskService.getAllTasks(req.user.id, {
        page: parseInt(page) || 1,
        limit: Math.min(parseInt(limit) || 10, 100),
        sort:   sort   || '-createdAt',
        status, priority,
    });
    res.set('X-Total-Count', total);
    res.json({ success: true, data: tasks, meta: { total } });
});

exports.getById = asyncHandler(async (req, res) => {
    const task = await taskService.getTaskById(req.params.id, req.user.id);
    res.json({ success: true, data: task });
});

exports.create = asyncHandler(async (req, res) => {
    const task = await taskService.createTask(req.user.id, req.body);
    res.status(201).json({ success: true, data: task });
});

exports.update = asyncHandler(async (req, res) => {
    const task = await taskService.updateTask(req.params.id, req.user.id, req.body);
    res.json({ success: true, data: task });
});

exports.markComplete = asyncHandler(async (req, res) => {
    const task = await taskService.markComplete(req.params.id, req.user.id);
    res.json({ success: true, data: task });
});

exports.remove = asyncHandler(async (req, res) => {
    await taskService.deleteTask(req.params.id, req.user.id);
    res.status(204).end();
});

exports.getStats = asyncHandler(async (req, res) => {
    const stats = await taskService.getStats(req.user.id);
    res.json({ success: true, data: stats });
});

How It Works

Step 1 — Controllers Handle HTTP Boundaries

The controller’s only job is to be the HTTP adapter for the service. It reads from req.params, req.query, req.body, and req.user — converting them into plain JavaScript values. It calls the appropriate service method. It formats the service’s return value into an HTTP response. If the service throws, the controller’s asyncHandler wrapper passes the error to Express’s error pipeline. The controller never contains a business rule or a database query.

Step 2 — Services Encode Business Rules

Business rules are the things that are true about your domain regardless of how the API is accessed. “A user cannot have more than 100 active tasks” is a business rule — it applies whether the task is created via the HTTP API, a CLI tool, or a background migration script. Putting it in the service means it is enforced everywhere the service is called. Putting it in the controller means it is only enforced on the HTTP path.

Step 3 — Repositories Isolate Database Access

Every Mongoose query lives in the repository. This is the only place that knows about Task.find(), Task.aggregate(), or Task.findOneAndUpdate(). Services call repository methods by name — taskRepository.findById(id, userId) — without knowing or caring how the query is constructed. This makes services testable with a mock repository and makes database changes confined to the repository layer.

Step 4 — Singleton Exports Create Shared Instances

Exporting new TaskRepository() and new TaskService() (rather than the class itself) means every module that requires the file gets the same instance. Node.js caches module exports, so the first require('./task.service') creates the instance and subsequent requires return the cached one. This is the idiomatic Node.js singleton — no singleton pattern boilerplate needed.

Step 5 — Testing Each Layer in Isolation

The three-layer architecture makes every layer independently testable. Repository tests use a real in-memory MongoDB database (with mongodb-memory-server). Service tests mock the repository — no database needed. Controller tests mock the service and use Supertest for HTTP assertions — no database, no service logic. Each layer’s tests are fast, focused, and do not depend on the infrastructure of layers above or below.

Real-World Example: Testing the Service Layer

// tests/task.service.test.js
const taskService    = require('../src/services/task.service');
const taskRepository = require('../src/repositories/task.repository');
const { ValidationError, NotFoundError } = require('../src/utils/AppError');

// Mock the repository — service tests run without a database
jest.mock('../src/repositories/task.repository');

describe('TaskService', () => {
    const userId = '64a1f2b3c8e4d5f6a7b8c9d1';

    beforeEach(() => jest.clearAllMocks());

    describe('createTask', () => {
        it('creates a task when under the active task limit', async () => {
            taskRepository.countActive.mockResolvedValue(5);
            taskRepository.create.mockResolvedValue({ _id: '...', title: 'Test', user: userId });

            const task = await taskService.createTask(userId, { title: 'Test', priority: 'high' });

            expect(taskRepository.create).toHaveBeenCalledWith(
                expect.objectContaining({ title: 'Test', user: userId })
            );
            expect(task.title).toBe('Test');
        });

        it('throws ValidationError when active task limit reached', async () => {
            taskRepository.countActive.mockResolvedValue(100);

            await expect(
                taskService.createTask(userId, { title: 'Test' })
            ).rejects.toThrow(ValidationError);

            expect(taskRepository.create).not.toHaveBeenCalled();
        });

        it('throws ValidationError for past due date', async () => {
            taskRepository.countActive.mockResolvedValue(0);
            const pastDate = new Date(Date.now() - 86400000).toISOString();

            await expect(
                taskService.createTask(userId, { title: 'Test', dueDate: pastDate })
            ).rejects.toThrow(ValidationError);
        });
    });

    describe('deleteTask', () => {
        it('throws NotFoundError when task does not exist', async () => {
            taskRepository.delete.mockResolvedValue(null);

            await expect(
                taskService.deleteTask('nonexistent-id', userId)
            ).rejects.toThrow(NotFoundError);
        });
    });
});

Common Mistakes

Mistake 1 — Putting business logic in the controller

❌ Wrong — business rule lives in the controller, untestable without HTTP:

exports.createTask = asyncHandler(async (req, res) => {
    const count = await Task.countDocuments({ user: req.user.id, status: { $ne: 'completed' } });
    if (count >= 100) return res.status(400).json({ message: 'Limit reached' });
    // ... creates task
});

✅ Correct — business rule in the service, controller stays thin:

exports.createTask = asyncHandler(async (req, res) => {
    const task = await taskService.createTask(req.user.id, req.body);
    res.status(201).json({ success: true, data: task });
});

Mistake 2 — Passing req/res to service methods

❌ Wrong — service knows about Express, cannot be tested without req mock:

async createTask(req) {   // accepts Express request object
    const userId = req.user.id;
    const { title } = req.body;
    // ...
}

✅ Correct — service accepts plain values only:

async createTask(userId, { title, priority, dueDate }) {  // plain values
    // testable without any Express dependency
}

Mistake 3 — Direct Mongoose calls in controllers

❌ Wrong — controller directly queries the database:

exports.getAll = asyncHandler(async (req, res) => {
    const tasks = await Task.find({ user: req.user.id }).sort('-createdAt').lean();
    res.json({ success: true, data: tasks });
});

✅ Correct — database access goes through repository via service:

exports.getAll = asyncHandler(async (req, res) => {
    const { tasks, total } = await taskService.getAllTasks(req.user.id, req.query);
    res.json({ success: true, data: tasks, meta: { total } });
});

Quick Reference

Layer File Pattern Accepts Returns
Route *.routes.js Express app/router Registered routes
Controller *.controller.js req, res, next HTTP response
Service *.service.js Plain JS values Domain objects or errors
Repository *.repository.js Plain JS values DB results or null
Model *.model.js Mongoose Model class

🧠 Test Yourself

You need to enforce a business rule: “A premium user can have unlimited tasks, but a free user is limited to 20.” Where should this logic live?