Skip to content

Beginner · Building the backend · topic 11 of 34 · 30 min

Route handlers that follow the contract

Explanation

A route handler is the function Express runs when a request matches a method and a path. Keep it thin: it reads the request, calls a service function that holds the rules, and the service calls data access code that holds the SQL. The handler then answers with exactly the status codes and JSON shapes the contract promised.

What a route handler is

Express is the most used web framework for Node.js. You tell it "when a GET request comes for /api/notes, run this function". That function is a route handler. It receives req (the request: path, body, logged-in user) and res (the response you send back).

javascript
import express from 'express';

const app = express();
app.use(express.json()); // read JSON request bodies into req.body

app.get('/api/notes', requireLogin, async (req, res) => {
  const notes = await notesService.list(req.user.id, req.query.q);
  res.json({ notes }); // status 200 is the default
});

app.listen(3000);
Runs on the server with Node.js. requireLogin (built in module 6) checks the session and sets req.user.

This course uses Express 5, the current major version. One big improvement: if an async handler throws an error or its promise is rejected, Express 5 passes the error to your error handler automatically. In Express 4 you had to wrap every handler in try/catch and call next(err) yourself.

Three layers: route, service, data access

Think of it like this: Think of a busy dhaba. The waiter takes your order and brings the plate, but does not cook. The head cook decides how the dish is made and refuses an order the kitchen cannot make. The store-room keeper only fetches rice and dal from the shelves. If each sticks to their job, a new waiter can join without learning to cook. Routes are the waiters, services are the cooks, data access is the store room.
LayerIts only jobNever does
Route (handler)Read req, call the service, send status and JSONWrite SQL or business rules
ServiceRules: validate input, check ownership, set timestamps, shape the resultTouch req or res
Data access (repository)Run SQL with placeholders and return rowsKnow about HTTP or status codes
javascript
// data/notes-repo.js: SQL only (better-sqlite3)
export function insertNote(db, row) {
  const result = db
    .prepare('INSERT INTO notes (user_id, title, body, pinned, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
    .run(row.user_id, row.title, row.body, row.pinned, row.created_at, row.updated_at);
  return { id: Number(result.lastInsertRowid), ...row };
}

export function deleteNote(db, id, userId) {
  return db.prepare('DELETE FROM notes WHERE id = ? AND user_id = ?').run(id, userId).changes;
}
The repository knows SQL and nothing else.
javascript
// services/notes-service.js: the rules
export function createNote(db, userId, input) {
  const now = new Date().toISOString();
  const row = insertNote(db, {
    user_id: userId,
    title: input.title.trim(),
    body: input.body ?? '',
    pinned: input.pinned ? 1 : 0,
    created_at: now,
    updated_at: now,
  });
  return toNoteJson(row);
}

// routes/notes.js: thin handlers
app.post('/api/notes', requireLogin, async (req, res) => {
  const note = createNote(db, req.user.id, req.body);
  res.status(201).json(note);
});

app.delete('/api/notes/:id', requireLogin, async (req, res) => {
  const changed = deleteNote(db, Number(req.params.id), req.user.id);
  if (changed === 0) throw new AppError(404, 'NOT_FOUND', 'Note not found');
  res.status(204).end();
});
Validation is added in the next topic. AppError is a small error class with a status and a code.

Rows in, contract JSON out

The database row and the JSON in the contract are not the same shape. The row has user_id and stores pinned as 1 or 0. The contract sends pinned as a real true or false and does not include user_id. One small function, toNoteJson, does this conversion, and every endpoint uses it, so the shape is the same everywhere.

EndpointSuccess statusBody
GET /api/notes200 OK{ notes: [ ... ] }
GET /api/notes/:id200 OKthe note
POST /api/notes201 Createdthe new note, with its id
PATCH /api/notes/:id200 OKthe updated note
DELETE /api/notes/:id204 No Contentnothing (res.status(204).end())
Any of the :id routes, note missing or not yours404 Not Found{ error: { code: "NOT_FOUND", message } }
Tip: req.params.id is always a string, even for /api/notes/7. Convert it with Number(req.params.id) before comparing with numbers, and treat anything that is not a positive whole number as "not found" or a 400 error.
Careful: Never take the owner from the request body or the URL (for example req.body.user_id). Anyone can send any number there. The owner always comes from the logged-in session: req.user.id.

Watch the lesson

Route handlers that follow the contract · 1/6

app.get('/api/notes', requireLogin, async (req, res) => {
  res.json({ notes: [] });
});

A route handler is the function Express runs for one method and one path.

Prefer a video by a teacher? Find free videos on “express js routes controllers services layers tutorial” on YouTube ↗

In real life: A train booking backend with clear layers

In a train booking service, the "book ticket" route only reads the request and returns 201 with the ticket. The booking service holds the rules: is the passenger list valid, are seats still free, what is the fare. The data access layer runs the SQL that locks a seat and saves the ticket. When the team later adds a mobile app and a ticket-counter screen, both reuse the same service. Only new thin routes are needed, and the rules live in one place.

Practice (0/3 done)

Task 1 · Code · Javascript

Write toNoteJson(row). A database row looks like { id, user_id, title, body, pinned: 1 or 0, created_at, updated_at }. Return the contract shape with keys in this order: id, title, body (use "" if body is null), pinned (true for 1, false for 0), created_at, updated_at. Do not include user_id.

Task 2 · Code · Javascript

Write the GET /api/notes handler as a plain function listNotes(req, store). req.userId is the logged-in user (missing when not logged in). store.notes is an array of database rows. Return { status, body }: 401 with { error: { code: "UNAUTHORIZED", message: "Please log in" } } when req.userId is missing; otherwise 200 with { notes } holding only this user's notes, pinned first, then newest updated_at first, each turned into JSON with toNoteJson (already written for you). Do not change the order of store.notes.

Task 3 · Code · Javascript

Write two handlers. createNote(req, store): req has userId, body ({ title, body?, pinned? }, already valid) and now (the current time as text). Build a row with keys in this order: id (store.nextId, then add 1 to store.nextId), user_id, title (trimmed), body ("" if missing), pinned (1 if body.pinned is true, else 0), created_at and updated_at (both req.now). Push it to store.notes and return { status: 201, body: toNoteJson(row) }. deleteNote(req, store): req.params.id is a STRING. Remove the note only if its id matches and user_id equals req.userId, then return { status: 204, body: null }. Otherwise return { status: 404, body: { error: { code: "NOT_FOUND", message: "Note not found" } } }.

Quick quiz

Question 1

1.In Express 5, what happens when an async route handler throws an error?

Question 2

2.Where should the SQL for deleting a note live?

Question 3

3.What should DELETE /api/notes/7 answer when the user deletes their own note?

Question 4

4.Where should the handler get the note owner's id from?

Question 5

5.The database row has pinned = 1. What does the API send?

Read more (free)

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

← Previous: The SQL behind each endpoint