The skeleton of your node-backend repo, cleaned up and laid out in the order you'd type it in an interview. Read the blueprint, study the files, then drill until you can write the whole thing from a blank folder in 15 minutes.
A request goes in from the outside. You build a module from the inside out: model, schema, repository, service, controller, routes. Each layer has one job and one thing it must never do.
Maps verb + path to a handler and attaches middleware such as authenticate.
Never holds logic.
Reads req, parses input with Zod, calls the service, sends the status code and JSON.
Never touches the database.
Holds the business rules: ownership checks and invariants. Throws NotFoundError and the other AppErrors.
Never sees req or res.
Data access behind an interface: an in-memory Map today, Prisma tomorrow.
Never throws HTTP errors.
Every throw lands here: Zod → 400, AppError → its own status, anything else → 500.
Always registered last.
Server answering. Run npm init, install, write tsconfig, then app.ts + index.ts with /health. Hit it once in the terminal.
Safety net. Write errors.ts, the error middleware and the 404 handler. Every later bug now comes back as clean JSON.
One entity, inside out. Model → Zod schema → repository interface + in-memory class → service → controller → routes → wire up in app.ts.
The extras they asked for. Pagination, filters, a role check with authorize("ADMIN"), a second entity.
Demo and talk. Run curl through the happy path and one error, then explain how you'd swap in Prisma, handle concurrency and add tests.
src/ config/env.ts # zod-validated process.env common/ errors.ts # AppError + subclasses, one file http.ts # ok() response helper error.middleware.ts auth.middleware.ts # authenticate + authorize modules/tasks/ task.model.ts task.schema.ts # zod schemas + inferred types task.repository.ts # interface + InMemory impl task.service.ts task.controller.ts task.routes.ts types/express.d.ts # req.user typing app.ts # createApp(): wiring only index.ts # listen + shutdown
The lines that go blank under pressure.
/tasks/:id. Always a string, so coerce it.?page=2. String or array, so coerce it.app.use(express.json()). Undefined without it.app.use(cookieParser())."42" into 42 for params and query values."false" into false. z.coerce.boolean() gives true.z.string().email() is the old form.next(err) automatically. No try/catch needed.create = async (req, res) => {} keeps this bound when you pass c.create../x.js even though the file is x.ts.process and Buffer show red lines.Type the missing piece, then press Enter or click Check. Blanks ignore spaces. Your progress stays in this browser.
Green lines appear somewhere in your answer (spacing ignored). Red lines are missing or written differently. Grade yourself honestly:
The structure is good: modules, a repository layer, constructor injection, a Zod-validated env and hashed refresh tokens with rotation. Most of these problems are wiring slips that a smoke test would catch. All 8 bugs are now fixed in the working tree (not committed yet), and tsc --noEmit passes. The other items are still open.
const body = schema.parse(req.body)) instead of the validate() middleware with TypedRequest. You get real types and no as unknown as RequestHandler casts. Express 5 sends the ZodError to your error handler on its own.z.stringbool() for query booleans and z.string().trim().min(1) for text. Avoid z.coerce on anything that isn't a number from the URL.req.cookies is undefined, so req.cookies.refreshToken throws a TypeError. Every call to /auth/refresh and /auth/logout returns 500.
import cookieParser from "cookie-parser"; app.use(express.json()); app.use(cookieParser());
The handlers finish without calling res.*, so the client waits until it times out.
await this.authService.logoutAll(req.user.userId); res.status(204).end();
validate(refreshTokenSchema) requires refreshToken in the JSON body, but the controller reads the cookie. A cookie-only client gets a 400. The validator is gone, and a missing cookie now returns 401.
router.post("/refresh", authController.refresh)
// controller
const refreshToken: string | undefined = req.cookies.refreshToken;
if (!refreshToken) throw new UnauthorizedError("Missing refresh token");With no cookie, hashToken(undefined) makes crypto.update() throw, so the user gets a 500. Treat a missing token as already logged out: clear the cookie and return 204.
if (refreshToken) await this.authService.logout(refreshToken);
res.clearCookie("refreshToken", cookieConfig.refreshToken);
res.status(204).end();z.coerce.string() accepts a missing titleString(undefined) is "undefined". POST {} creates a task titled "undefined", and {"title": 123} passes too.
title: z.string().trim().min(1).max(200),
?completed=false filters for completed tasksz.coerce.boolean() runs Boolean("false"), which is true. Any non-empty string becomes true.
completed: z.stringbool().optional(),
updateMany and deleteMany return { count }, which is always truthy, so if (!updatedTask) never fires. Another user's task id gets 200 { count: 0 } instead of a 404, and the client never receives the updated task.
const { count } = await this.taskRepository.update(prisma, { id, userId, ...data });
if (count === 0) throw new NotFoundError(`Task ${id} not found`);
return this.findById(id, userId);
// delete: same count check, and the controller now sends 204 with no body201 means "created". The response body is the full Prisma row, bcrypt hash included. It now returns 204 with no body.
await this.userService.delete(req.params.id); res.status(204).end();
AppError returns {error:{message,code}}, a 500 returns {message} and validation returns {message, errors}. A malformed JSON body (a SyntaxError from express.json) becomes a 500. The Code tab has an error middleware that covers all four cases with one shape.
findSession runs outside the transaction, so two parallel calls with the same token both mint new sessions. Inside the transaction, revoke with updateMany({ where: { tokenHash, revokedAt: null } }) and require count === 1. A revoked token being reused is a theft signal, so revoke all of that user's sessions.
any in the user repository spreads into (user as any).passwordType the arguments with Prisma's own types (Prisma.UserCreateInput, Prisma.UserUpdateInput) and use findUnique({ where: { email } }), since email is @unique. The cast then goes away.
as unknown as RequestHandler on four routesThis is what happens when TypedRequest generics fight Express's own types. With parse-in-controller, handlers take a plain Request and the casts disappear.
+ 7 appears twice even though jwtConfig.refreshToken.expiresInDays exists, and the cookie maxAge sets its own 7 days. Derive all three from the config value.
Express 5 already forwards async errors. register and login rely on that, but logout and refresh don't. Pick one style.
if (!tasks) can't fire because findMany returns an array, and { ...query, search: query.search } is just query.
Unknown routes get Express's HTML "Cannot GET". Add a JSON 404 before the error middleware.
logoutSchema is unused, a commented-out parse is still there, and the error message reads "Missing Authorization error".
There's no .env.example, and build doesn't run prisma generate. Add "postinstall": "prisma generate".