What this module covers: Modules F-1 through F-7 gave you all the individual pieces. This module assembles them into a real, structured project. We build a Blog API — full CRUD for posts and users, proper project structure, environment configuration, error handling, and a working Prisma database layer. By the end you will have a template that reflects how real Node.js applications are organised, and you will understand why each layer exists.
POST /users — register a user GET /users/:id — get user profile
GET /posts — list all published posts (with author) GET /posts/:id — get a single post POST /posts — create a post (requires userId in body) PATCH /posts/:id — update a post DELETE /posts/:id — delete a post
This is deliberately simple — no authentication yet (that's P-2). The goal is clean project structure and solid fundamentals.
This is the layered architecture we will formalise in P-1. For now the pattern is: routes handle HTTP, the db/ layer handles data access, and middleware/ holds cross-cutting concerns.
datasource db { provider = "postgresql" url = env("DATABASE_URL") }
model User { id Int @id @default(autoincrement()) name String email String @unique createdAt DateTime @default(now()) posts Post[] }
model Post { id Int @id @default(autoincrement()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Run the migration:
npx prisma migrate dev --name init
This creates the tables in your PostgreSQL database and generates the Prisma Client.
// GET /users/:id — get user profile with their posts router.get('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' });
// GET /posts/:id — get a single post router.get('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' });
// PATCH /posts/:id — update a post router.patch('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' });
// DELETE /posts/:id — delete a post router.delete('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' });
await prisma.post.delete({ where: { id } }); res.status(204).send(); } catch (err) { if (err.code === 'P2025') { return res.status(404).json({ error: 'Post not found' }); } next(err); } });
// ── Error handler (must be last) ────────────────────────── app.use(errorHandler);
// ── Start ───────────────────────────────────────────────── const server = app.listen(PORT, () => { console.log(`Blog API running on http://localhost:${PORT}`); console.log(`Environment: ${process.env.NODE_ENV}`); });
// ── Graceful shutdown ────────────────────────────────────── async function shutdown(signal) { console.log(`\n${signal} received — starting graceful shutdown`);
// Stop accepting new connections. Requests already in flight are // allowed to finish; server.close()'s callback fires once they have. server.close(async (err) => { if (err) { console.error('Error while closing HTTP server:', err); process.exitCode = 1; }
// Safety net: if a connection never closes (e.g. a hung keep-alive // socket), don't let the process hang forever — force-exit. setTimeout(() => { console.error('Forced shutdown after timeout'); process.exit(1); }, 10_000).unref(); }
F-3 already covered why SIGTERM handling matters: Docker and Kubernetes send SIGTERM before killing a container (during a deploy, a scale-down, or a node drain), and expect the process to clean up and exit within a grace period. Without a handler, Node exits immediately on SIGTERM — any request mid-flight gets cut off, and the Prisma connection pool is torn down uncleanly instead of releasing its connections back to Postgres. The block above closes the loop that this module's entry point was missing: it stops server from accepting new connections, waits for in-flight requests to finish, disconnects Prisma, and only then exits — with a timeout so a stuck shutdown doesn't hang the container past its grace period.
# Create a user curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name": "Jatin", "email": "jatin@example.com"}' # → {"id":1,"name":"Jatin","email":"jatin@example.com","createdAt":"..."}
# Create a post curl -X POST http://localhost:3000/posts \ -H "Content-Type: application/json" \ -d '{"title":"My First Post","content":"Hello world!","authorId":1,"published":true}' # → {"id":1,"title":"My First Post",...,"author":{"id":1,"name":"Jatin"}}
# List posts curl http://localhost:3000/posts
# Get user profile with their posts curl http://localhost:3000/users/1
# Update a post curl -X PATCH http://localhost:3000/posts/1 \ -H "Content-Type: application/json" \ -d '{"title":"My Updated Post"}'
# Delete a post curl -X DELETE http://localhost:3000/posts/1
# Test 404 curl http://localhost:3000/posts/9999 # → {"error":"Post not found"}
# Test validation curl -X POST http://localhost:3000/posts \ -H "Content-Type: application/json" \ -d '{"title":"Missing content"}' # → {"error":"title, content, and authorId are required"}
curl is fine for one-off checks, but re-typing a multi-line command every time you tweak a request body gets old fast. Postman (or its open-source cousin, Insomnia) saves each request — method, URL, headers, body — so you can re-run it with one click instead of retyping it:
Create a new request, set the method (POST) and URL (http://localhost:3000/posts).
Under Body, choose raw → JSON, and paste the same payload you used with curl -d.
Send it, inspect the response body and status code in the response pane, then save the request into a collection.
Group related requests (Create User, Create Post, Get Posts, Delete Post) into one collection named after this API — that collection becomes a living, clickable set of examples for the next person who works on this API, curl commands buried in a README don't.
For anything you'll run more than a couple of times, a saved Postman request beats retyping a curl command.
src/ ├── db/ → Data access layer (only place that talks to DB) ├── routes/ → HTTP layer (only place that reads req, writes res) ├── middleware/ → Cross-cutting concerns (logging, errors, auth later) └── index.js → Wires everything together
Think of it like a restaurant: routes/ is the waitstaff — they take orders and deliver food but never touch the stove; db/ is the kitchen — the only place raw SQL or Prisma calls happen; middleware/ is the manager who steps in for anything cross-cutting.
Why separate db/ from routes/?
Your route handlers are now thin — they validate input, call a function, and send a response. All database logic is in db/. If you switch from Prisma to raw SQL tomorrow, you change db/ only. Routes stay the same.
Production story: on an on-chain event indexer, a route handler once called pool.query() directly, as a "quick fix" to unblock a demo. Six months later, forty separate call sites across the routes tree depended on that raw SQL — every one of them written the same way, by different people who saw the precedent and copied it. When a column's type needed to change, there was no single db/ layer to update; it took grepping the entire routes tree, call site by call site, to find and fix every place that touched that column. The convention this module builds — routes never touch the database directly — is exactly what would have contained that change to one file.
Why a dedicated error handler?
All errors flow to one place. You change error formatting once. You add logging once. Every route benefits automatically.
Why dotenv/config at the top of index.js?
Environment variables must be loaded before anything else reads them. Loading it first in the entry file ensures process.env.DATABASE_URL is available when prisma.js is imported.
A real application has layers: routes (HTTP), services (logic), and data (database). Even this small app separates HTTP handling from database access.
dotenv/config loads environment variables first, before any imports that need them.
One Prisma client instance per application. Never create one per request.
Pass errors to next(err) in every route handler. The global error handler catches them all.
A health check endpoint (GET /health) is a small addition that makes your app ready for Docker and Kubernetes load balancer checks.
.env.example is committed; .env is not. Everyone on the team knows what configuration the app needs.
Handle SIGTERM in the entry point: stop accepting new connections with server.close(), disconnect Prisma, then exit. Docker and Kubernetes rely on this to shut containers down cleanly.
Next: Event Emitters — the mechanism underlying everything in Node.js from HTTP servers to file streams.
Knowledge Check
Why is it important to create a single PrismaClient instance and export it as a singleton rather than instantiating it inside route handlers?
In the context of the blog API's error handling, why do the route handlers catch errors and pass them to next(err)?
Why is .env.example committed to version control while .env is added to .gitignore?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.