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.
What We Are Building
A Blog API with two resources:
Users — register, read profile
Posts — create, read, update, delete blog posts
Endpoints:
text
This is deliberately simple — no authentication yet (that's P-2). The goal is clean project structure and solid fundamentals.
Project Structure
text
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.
Step 1: Project Initialisation
bash
Update package.json:
json
Create .gitignore:
text
Create .env:
text
Create .env.example (committed — shows what vars are needed without exposing values):
text
Step 2: Database Schema
prisma
Run the migration:
bash
This creates the tables in your PostgreSQL database and generates the Prisma Client.
Step 3: Prisma Client Singleton
javascript
One PrismaClient instance for the entire application. Creating one per request wastes connections and causes pool exhaustion.
Step 4: Route Handlers
javascript
javascript
Step 5: Error Handler Middleware
javascript
Step 6: Application Entry Point
javascript
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.
Step 7: nodemon for Development
json
bash
Nodemon watches src/ and restarts the server whenever you save a file.
Testing the API
bash
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.
What This Structure Gives You
Looking at the project layout again:
text
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.
What Comes Next
This application is the baseline. As you move into the Practitioner phase, you will add:
P-1: Proper layered architecture (service layer between routes and DB)
P-2: JWT authentication — protect routes, know who is making requests
P-3: TypeScript — full type safety from route parameters to database results
P-4: Zod validation — replace manual if (!title) checks with schema declarations
P-5: Tests — Jest and Supertest to verify every endpoint automatically
P-8: Docker — containerise the entire app and database
Each addition builds on the structure you have here. The fundamentals do not change — they get reinforced.
Summary
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.
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
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
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
}
// src/routes/users.jsimport{Router}from'express';importprismafrom'../db/prisma.js';const router =Router();// POST /users — register a userrouter.post('/',async(req, res, next)=>{try{const{ name, email }= req.body;if(!name ||!email){return res.status(400).json({error:'name and email are required'});}const user =await prisma.user.create({data:{ name, email },}); res.status(201).json(user);}catch(err){if(err.code==='P2002'){return res.status(409).json({error:'Email already registered'});}next(err);}});// GET /users/:id — get user profile with their postsrouter.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'});const user =await prisma.user.findUnique({where:{ id },include:{posts:{where:{published:true},orderBy:{createdAt:'desc'},select:{id:true,title:true,createdAt:true},},},});if(!user)return res.status(404).json({error:'User not found'}); res.json(user);}catch(err){next(err);}});exportdefault router;
// src/routes/posts.jsimport{Router}from'express';importprismafrom'../db/prisma.js';const router =Router();// GET /posts — list published posts with author namerouter.get('/',async(req, res, next)=>{try{const posts =await prisma.post.findMany({where:{published:true},orderBy:{createdAt:'desc'},include:{author:{select:{id:true,name:true}},},}); res.json(posts);}catch(err){next(err);}});// GET /posts/:id — get a single postrouter.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'});const post =await prisma.post.findUnique({where:{ id },include:{author:{select:{id:true,name:true}}},});if(!post)return res.status(404).json({error:'Post not found'}); res.json(post);}catch(err){next(err);}});// POST /posts — create a postrouter.post('/',async(req, res, next)=>{try{const{ title, content, authorId, published =false}= req.body;if(!title ||!content ||!authorId){return res.status(400).json({error:'title, content, and authorId are required'});}const parsedAuthorId =parseInt(authorId,10);if(isNaN(parsedAuthorId))return res.status(400).json({error:'authorId must be a number'});const post =await prisma.post.create({data:{ title, content, published,authorId: parsedAuthorId,},include:{author:{select:{id:true,name:true}}},}); res.status(201).json(post);}catch(err){if(err.code==='P2003'){return res.status(400).json({error:'Author not found'});}next(err);}});// PATCH /posts/:id — update a postrouter.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'});const{ title, content, published }= req.body;const post =await prisma.post.update({where:{ id },data:{...(title !==undefined&&{ title }),...(content !==undefined&&{ content }),...(published !==undefined&&{ published }),},include:{author:{select:{id:true,name:true}}},}); res.json(post);}catch(err){if(err.code==='P2025'){return res.status(404).json({error:'Post not found'});}next(err);}});// DELETE /posts/:id — delete a postrouter.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);}});exportdefault router;
// src/middleware/errorHandler.jsexportfunctionerrorHandler(err, req, res, next){console.error(`[${newDate().toISOString()}] ${req.method}${req.path}`, err);// Prisma errors that slipped through route handlersif(err.code==='P2025'){return res.status(404).json({error:'Record not found'});}if(err.code==='P2002'){return res.status(409).json({error:'A record with that value already exists'});}// Express JSON parse errorif(err.type==='entity.parse.failed'){return res.status(400).json({error:'Invalid JSON in request body'});}const status = err.statusCode|| err.status||500;const message = process.env.NODE_ENV==='production'?'Internal server error': err.message; res.status(status).json({error: message });}
// src/index.jsimport'dotenv/config';importexpressfrom'express';importusersRouterfrom'./routes/users.js';importpostsRouterfrom'./routes/posts.js';import{ errorHandler }from'./middleware/errorHandler.js';importprismafrom'./db/prisma.js';const app =express();constPORT= process.env.PORT||3000;// ── Middleware ─────────────────────────────────────────────app.use(express.json());// Health check — useful for Docker and load balancersapp.get('/health',(req, res)=>{ res.json({status:'ok',uptime: process.uptime()});});// ── Routes ────────────────────────────────────────────────app.use('/users', usersRouter);app.use('/posts', postsRouter);// ── 404 handler ───────────────────────────────────────────app.use((req, res)=>{ res.status(404).json({error:`Route ${req.method}${req.path} not found`});});// ── 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 ──────────────────────────────────────asyncfunctionshutdown(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;}try{await prisma.$disconnect();console.log('Prisma disconnected');}catch(disconnectErr){console.error('Error disconnecting Prisma:', disconnectErr); process.exitCode=1;}console.log('Shutdown complete'); process.exit(process.exitCode??0);});// 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();}process.on('SIGTERM',()=>shutdown('SIGTERM'));process.on('SIGINT',()=>shutdown('SIGINT'));
# Create a usercurl-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 postcurl-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 postscurl http://localhost:3000/posts
# Get user profile with their postscurl http://localhost:3000/users/1
# Update a postcurl-X PATCH http://localhost:3000/posts/1 \-H"Content-Type: application/json"\-d'{"title":"My Updated Post"}'# Delete a postcurl-X DELETE http://localhost:3000/posts/1
# Test 404curl http://localhost:3000/posts/9999
# → {"error":"Post not found"}# Test validationcurl-X POST http://localhost:3000/posts \-H"Content-Type: application/json"\-d'{"title":"Missing content"}'# → {"error":"title, content, and authorId are required"}
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