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({...}) |
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.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 |