Beginner · Building the backend · topic 11 of 34 · 30 min
Route handlers that follow the contract
Explanation
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).
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);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
| Layer | Its only job | Never does |
|---|---|---|
| Route (handler) | Read req, call the service, send status and JSON | Write SQL or business rules |
| Service | Rules: validate input, check ownership, set timestamps, shape the result | Touch req or res |
| Data access (repository) | Run SQL with placeholders and return rows | Know about HTTP or status codes |
// 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;
}// 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();
});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.
| Endpoint | Success status | Body |
|---|---|---|
| GET /api/notes | 200 OK | { notes: [ ... ] } |
| GET /api/notes/:id | 200 OK | the note |
| POST /api/notes | 201 Created | the new note, with its id |
| PATCH /api/notes/:id | 200 OK | the updated note |
| DELETE /api/notes/:id | 204 No Content | nothing (res.status(204).end()) |
| Any of the :id routes, note missing or not yours | 404 Not Found | { error: { code: "NOT_FOUND", message } } |
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)
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.
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.
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
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.