The route layer has exactly three jobs: parse and validate the request, call one service method, and turn whatever comes back into a status code and a body.
That is a short list, and the discipline is in what it leaves out. If you can read a business rule in a controller, that rule is in the wrong file.
The shape
// services/course-service/src/controllers/course.controller.js
export async function enrol(req, res) {
const enrolment = await courseService.enrol({
courseId: req.valid.params.id,
actor: req.user,
});
res.status(201).json({ data: enrolment });
}
Four lines, and three of them are translation. The validation happened in middleware before this ran; the rules live in courseService.enrol; the controller knows only that a successful enrolment is a 201.
Why the rule pays
A service method that knows nothing about HTTP can be called from anywhere: an HTTP route, a CLI script, a queue consumer, a test. The moment a rule moves into the controller, it is reachable from exactly one of those, and the test for it needs a server.
It also decides where errors are translated. The service throws a domain error - NotFoundError, ConflictError - which says what went wrong in the language of the problem. One error-handling middleware at the edge maps those to status codes. Call the same service from a queue consumer and NotFoundError still means "give up, do not retry", without anyone having to know that elsewhere it means 404.
Validation belongs at the edge
Everything arriving from outside is a string of unknown shape until something has checked it. Doing that in the route layer means every layer below can assume its inputs are the right type and in range:
router.post(
'/id/:id/enrolments',
requireAuth,
validate({ params: courseIdParam }),
courseController.enrol,
);
By the time the controller runs, req.valid.params.id is a UUID. Not "probably a UUID": the request was rejected otherwise, with a 422 naming the field.
What goes wrong without it
Two things, reliably.
The first is duplication. A rule written in a controller gets copied into the next controller that needs it, and then the two copies drift. The one in the admin endpoint keeps the check nobody remembered to add to the public one.
The second is that the application becomes untestable without a running server. Every test needs a port, a client and a teardown; the suite takes minutes instead of seconds, so it gets run less, so it catches less.
Both are slow failures. Neither looks like a problem on the day you write the controller.