Node.js REST API · Foundations

Controllers

A controller is the translator between HTTP and your business logic: it reads the request, calls a service and turns the result into a response. Keeping controllers thin makes the whole API easier to test and change.

Beginner 7 min Lesson 5 of 25
Before you start:Routing
0/25 in Node.js REST API
On this page · 7 min · Beginner

Learning objectives#

  • Explain what belongs in a controller (and what does not)
  • Create controllers with injected services
  • Map service results to HTTP status codes
  • Unit-test a controller with fake req/res objects

Explanation#

A controller does three things: read input (params, query, body, req.user), call one service method, send the response with the right status. Business rules (“a course needs lessons before publishing”) live in the service.

Use factories: createCourseController({ courseService }) returns an object of handler functions. The dependencies are passed in, so tests can pass fakes and production can pass the real MySQL-backed service.

Services report outcomes either by throwing typed errors (handled by the error middleware) or by returning results such as { error: "not_found" }. Pick one style per project; this lesson shows results, the Error Handling lesson shows exceptions.

Composition root: one place (app.js) creates repositories, services and controllers and wires them into routers.

Because handlers are plain functions, you can test them by calling them with a fake req and res — no network or port needed.

Syntax#

JavaScript Illustrative
export function createCourseController({ courseService }) {
  return {
    async get(req, res) {
      const course = await courseService.get(Number(req.params.id));
      if (!course) return res.status(404).json({ error: 'Course not found' });
      res.json(course);
    },
  };
}

Code example: A course controller with an injected service#

JavaScriptcontrollers.js Illustrative
import express from 'express';


// services/courses.service.js — business rules, no HTTP
function createCourseService(store) {
  return {
    list: ({ level }) => store.filter(c => !level || c.level === level),
    get: id => store.find(c => c.id === id) ?? null,
    publish(id) {
      const course = this.get(id);
      if (!course) return { error: 'not_found' };
      if (course.lessons === 0) return { error: 'no_lessons' };
      course.published = true;
      return { course };
    },
  };
}


// controllers/courses.controller.js — HTTP in, HTTP out
function createCourseController({ courseService }) {
  return {
    list(req, res) {
      res.json(courseService.list({ level: req.query.level }));
    },
    get(req, res) {
      const course = courseService.get(Number(req.params.id));
      if (!course) return res.status(404).json({ error: 'Course not found' });
      res.json(course);
    },
    publish(req, res) {
      const { course, error } = courseService.publish(Number(req.params.id));
      if (error === 'not_found') return res.status(404).json({ error: 'Course not found' });
      if (error === 'no_lessons') return res.status(422).json({ error: 'Add at least one lesson before publishing' });
      res.json(course);
    },
  };
}


// app.js — composition root
const store = [
  { id: 1, title: 'Node.js REST APIs', level: 'beginner', lessons: 12, published: false },
  { id: 2, title: 'Advanced Express', level: 'advanced', lessons: 0, published: false },
];
const controller = createCourseController({ courseService: createCourseService(store) });


// 1. Unit test: call the controller directly with a fake response
const fakeRes = {
  statusCode: 200,
  status(code) { this.statusCode = code; return this; },
  json(body) { this.body = body; return this; },
};
controller.publish({ params: { id: '2' } }, fakeRes);
console.log('unit test:', fakeRes.statusCode, fakeRes.body.error);


// 2. The same controller, mounted in Express
const router = express.Router();
router.get('/', controller.list);
router.get('/:id', controller.get);
router.post('/:id/publish', controller.publish);


const app = express();
app.use('/api/courses', router);


const server = app.listen(3103, async () => {
  const base = 'http://localhost:3103/api/courses';
  for (const [method, path] of [['GET', '?level=advanced'], ['POST', '/1/publish'], ['POST', '/2/publish'], ['GET', '/9']]) {
    const r = await fetch(base + path, { method });
    console.log(method.padEnd(4), path.padEnd(16), r.status, await r.text());
  }
  server.close();
});
Expected output
unit test: 422 Add at least one lesson before publishing
GET  ?level=advanced  200 [{"id":2,"title":"Advanced Express","level":"advanced","lessons":0,"published":false}]
POST /1/publish       200 {"id":1,"title":"Node.js REST APIs","level":"beginner","lessons":12,"published":true}
POST /2/publish       422 {"error":"Add at least one lesson before publishing"}
GET  /9               404 {"error":"Course not found"}

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. The service owns the rule “no lessons → cannot publish” and knows nothing about HTTP.
  2. The controller maps service outcomes to 200, 404 and 422.
  3. The composition root creates the service and injects it into the controller.
  4. A fake res object lets us unit-test the controller without starting a server.
  5. The same controller functions are mounted on an Express router unchanged.

Real-world use case#

LearnHub’s CoursesController is tested with a fake service in milliseconds, while the service is tested with an in-memory repository. Only a handful of slower integration tests use a real MySQL database.

Common mistakes#

Fat controllers

SQL, validation, business rules and formatting in one handler make reuse (e.g. from a CLI or queue worker) impossible.

Services that know about req/res

A service taking req or calling res.status cannot be reused or tested on its own. Pass plain values in, return plain values out.

Importing singletons everywhere

Controllers that import a global db cannot be tested with fakes. Inject dependencies.

Best practices#

  • Thin controllers: input → service → response
  • Inject dependencies through factories
  • One composition root
  • Unit-test controllers with fakes

Remember this#

Controller = HTTP translator. Service = rules.

Factories + injection = easy fakes.

One service call per handler is a good smell.

Practice exercise#

Your task: Add an archive(req, res) controller method that calls courseService.archive(id) and returns 404 for not_found, 409 for already_archived, and 200 with the course otherwise. Test it with a fake service and fake res.

Need a hint?
The fake service can be an object literal: { archive: () => ({ error: "already_archived" }) }.
Show solution
JavaScript Illustrative
function createCourseController({ courseService }) {
  return {
    archive(req, res) {
      const { course, error } = courseService.archive(Number(req.params.id));
      if (error === 'not_found') return res.status(404).json({ error: 'Course not found' });
      if (error === 'already_archived') return res.status(409).json({ error: 'Course is already archived' });
      res.json(course);
    },
  };
}


const fakeService = { archive: () => ({ error: 'already_archived' }) };
const res = { status(c) { this.code = c; return this; }, json(b) { this.body = b; return this; } };
createCourseController({ courseService: fakeService }).archive({ params: { id: '1' } }, res);
console.log(res.code, res.body.error);   // 409 Course is already archived

Mini quiz#

Controllers quizMultiple choice Question 1 of 3

Where should the rule “a course needs at least one lesson to be published” live?

Score so far: 0/0

Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.