When to Split Further
Objectives
By the end of this lesson, you should be able to:
- Explain what a service layer adds beyond a controller
- Explain what a repository layer adds beyond a model
- Recognize when plain MVC is enough, and when a project has outgrown it
💡 Why this matters: MVC (Lessons 1 through 5) is enough structure for most small-to-medium apps, including everything built in this course. Once real business rules and a real database enter the picture, in the very next course in this track, two more layers become genuinely useful, this lesson previews them so they’re recognizable when they show up.
⚠️ A note on verification: every snippet and every response shown below was actually run and tested with real HTTP requests.
The Problem Plain MVC Starts to Show
A controller (Lesson 3) is meant to just coordinate, but real business rules, “a user can only have 3 free-tier tasks,” “an order can’t ship without a valid address,” don’t belong in a model (Lesson 2, plain data operations) or feel right piled into a controller (meant to stay thin). As an app grows, this logic needs a home of its own.
A Service Layer for Business Rules
// services/taskService.js
const taskRepository = require('../repositories/taskRepository');
const MAX_TASKS = 3;
function listTasks() {
return taskRepository.findAll();
}
function createTask(data) {
if (taskRepository.count() >= MAX_TASKS) {
const err = new Error('Task limit reached');
err.statusCode = 400;
err.isOperational = true;
throw err;
}
return taskRepository.insert({ title: data.title, done: false });
}
module.exports = { listTasks, createTask };
// controllers/taskController.js (using the service instead of the model directly)
const taskService = require('../services/taskService');
function create(req, res, next) {
try {
const task = taskService.createTask(req.body);
res.status(201).json(task);
} catch (err) {
next(err);
}
}
curl -X POST http://localhost:4604/tasks -H "Content-Type: application/json" -d '{"title":"Second"}'
curl -X POST http://localhost:4604/tasks -H "Content-Type: application/json" -d '{"title":"Third"}'
curl -w " [%{http_code}]" -X POST http://localhost:4604/tasks -H "Content-Type: application/json" -d '{"title":"Fourth"}'
{"id":2,"title":"Second","done":false}
{"id":3,"title":"Third","done":false}
{"error":"Task limit reached"} [400]
A service sits between the controller and the data layer, holding business logic (Module 8’s custom errors, thrown from createTask itself, not the controller), the controller stays exactly as thin as Lesson 3 described, try/catch plus next(err), the actual rule (the task limit) lives in one place, the service, reusable anywhere that rule needs enforcing, not just from this one controller.
A Repository Layer for Data Access
// repositories/taskRepository.js
let tasks = [{ id: 1, title: 'Write lesson', done: false }];
let nextId = 2;
module.exports = {
findAll: () => tasks,
findById: (id) => tasks.find(t => t.id === id),
insert: (task) => { const record = { id: nextId++, ...task }; tasks.push(record); return record; },
count: () => tasks.length
};
A repository is what Lesson 2’s model becomes once a real database enters the picture, it owns the actual storage access, findAll, findById, insert, and nothing else, the service layer above calls it without knowing or caring whether it’s backed by an in-memory array (as here) or a real SQL query, this is the same benefit Lesson 2 described for models, taken one step further: swapping the repository’s internals for a real PostgreSQL connection, in the next course in this track, changes nothing in taskService.js at all.
When This Is (and Isn’t) Worth It
For a small app, one or two resources, straightforward CRUD, plain MVC (model, controller, routes) is genuinely enough, adding service and repository layers to a to-do list app would be over-engineering, more files and indirection than the actual complexity justifies. The signal to split further is real business logic accumulating, validation rules more complex than “is this field present,” calculations, multi-step operations touching more than one model, once that shows up, a service layer gives it a proper home, and once a real database (and, later, more complex queries or multiple data sources) is involved, a repository layer becomes worth the extra structure too.
Try It
- Take the
taskService/taskRepositoryexample above, and add a second business rule (for example, rejecting a task title longer than 100 characters) to the service layer. - Explain, in your own words, why the task limit check belongs in
taskService.jsrather than intaskController.jsortaskRepository.js. - Sketch, in words, what would change in this structure if
taskRepository.js’s in-memory array were replaced with real database queries, and confirm thattaskService.jsandtaskController.jswouldn’t need to change at all. - For a project you’ve built in this course, decide honestly whether it would benefit from a service layer yet, or whether plain MVC is still the right amount of structure, and explain why.
Recap
- A service layer holds business logic between the controller and data access, keeping controllers thin and centralizing rules that don’t belong in either a model or a controller.
- A repository layer is what a model becomes once a real database is involved, isolating storage details so a service never needs to know how or where data is actually stored.
- Plain MVC is enough for most small-to-medium apps, service and repository layers earn their complexity once real business rules and a real database are genuinely part of the picture, exactly where the next course in this track picks up.
Next lesson: a complete multi-page MVC project, putting every piece from this module together.