Test and ship Express
Separate domain and transport
Keep note rules independent of Express so route tests stay focused and domain tests stay fast.
A route does translation. It turns HTTP into a call on your code, and turns the result back into HTTP. The rules about notes, who may edit one, what a valid title is, what happens on delete, are not HTTP. They belong in plain functions that have never heard of req.
I call the two sides transport and domain. Keeping them apart is what makes the app testable in two speeds: a handful of request tests for the HTTP contract, and many fast tests for the rules.
A route that knows too much
Here is an update route the way it tends to grow:
notesRouter.put('/:id', async (req, res) => {
const note = await db.query('select * from notes where id = $1', [req.params.id])
if (!note) return res.status(404).json({ error: 'Note not found' })
if (note.ownerId !== req.session.userId) return res.status(403).json({ error: 'Forbidden' })
const title = req.body.title?.trim()
if (!title || title.length > 120) return res.status(400).json({ error: 'Invalid title' })
await db.query('update notes set title = $1 where id = $2', [title, note.id])
res.json({ ...note, title })
})
Everything is in one place, which feels efficient. But the ownership rule can only be tested by sending HTTP with a session cookie, and the SQL is welded to the response codes. Change the database and you edit routes.
Move the rules out
src/notes/service.js gets the rules, expressed with plain inputs and plain outcomes:
import { HttpError, NotFoundError } from '../errors.js'
import { validateNote } from './validate.js'
export function createNotesService(repo) {
return {
async update({ id, userId, input }) {
const note = await repo.find(id)
if (!note) throw new NotFoundError('Note not found')
if (note.ownerId !== userId) throw new HttpError(403, 'Forbidden')
const result = validateNote({ ...note, ...input })
if (result.errors) throw new HttpError(400, result.errors.join(', '))
return repo.save({ ...note, ...result.value })
},
}
}
The service receives a repo and returns functions. It takes ids and user ids, not requests. It throws the same error classes the error middleware already understands.
The route shrinks to translation:
notesRouter.put('/:id', async (req, res) => {
const note = await notes.update({
id: req.params.id,
userId: req.session.userId,
input: req.body,
})
res.json(note)
})
Read the params, read the session, read the body, call one function, send the result. Errors propagate to the error middleware on their own, because this is Express 5.
Two kinds of tests, two speeds
The ownership rule now has a direct test with no HTTP in it:
test('a user cannot update someone else\'s note', async () => {
const repo = { find: async () => ({ id: 1, ownerId: 2, title: 'Theirs' }), save: async n => n }
const notes = createNotesService(repo)
await assert.rejects(
notes.update({ id: 1, userId: 7, input: { title: 'Mine' } }),
{ status: 403 },
)
})
Runs in a millisecond, and you can write twenty variations without thinking about cookies. The route test from the previous lesson stays, but there is only one of it per route, checking that 403 from the service becomes a 403 response.
Inject at construction
createApp({ config, notes }) receives the service, and src/server.js builds the real one:
const repo = createPostgresRepo(pool)
const notes = createNotesService(repo)
const app = createApp({ config, notes })
Tests pass a service built on an in-memory repo instead. Nothing in app.js imports a database driver.
The line I hold is: no req or res below the router. If you find yourself passing req into a service “just for the user id”, pass the user id. It’s one argument, and it keeps the whole layer testable.
Try this on your project: find your longest route handler, move one rule into a plain function, and write its test. Then compare how the route test and the new test read.
Lesson completed